diff --git a/.claude/prompt/fix-doctor-poster-download-image.md b/.claude/prompt/fix-doctor-poster-download-image.md new file mode 100644 index 0000000..34ec56e --- /dev/null +++ b/.claude/prompt/fix-doctor-poster-download-image.md @@ -0,0 +1,169 @@ +# رفع مشکل عکس نادرست در پوستر دانلودی صفحه پزشک (اشتراک‌گذاری) + +## پروژه + +`nobat724_front` + +## زمینه + +در صفحه‌ی عمومی پزشک (`/doctor/[slug]`) یک دکمه‌ی «اشتراک گذاری» هست که یک Modal باز می‌کند. داخل Modal گزینه‌ی «دانلود» یک پوستر PDF می‌سازد. پوستر شامل عکس پروفایل پزشک، QR کد، لوگو و اطلاعات پزشک است. + +مسیر تست: +`http://localhost:3000/doctor/f58d3dba-6ce0-4bd4-85d9-9d787a0b9324` → «اشتراک گذاری» → «دانلود» + +## مشکل / هدف + +وقتی روی «دانلود» کلیک می‌شود، پوستر PDF دانلود می‌شود ولی **عکس آن درست نیست** — عکس پروفایل پزشک و/یا QR کد در فایل خروجی خالی/خراب/ناقص است. + +**علت ریشه‌ای:** `handleDownloadPDF` بلافاصله `html2canvas` را صدا می‌زند بدون اینکه منتظر بارگذاری کامل تصاویر بماند: + +1. عکس پروفایل با `next/image` (``) رندر می‌شود که از دامنه‌ی remote (`api.clinic-pro.ir`) از طریق optimizer آدرس `/_next/image?url=...` می‌آید و lazy load دارد؛ ممکن است هنگام capture هنوز paint نشده باشد. +2. QR کد به‌صورت async در `useEffect` تولید می‌شود (`QRCode.toDataURL(...).then(setQrCodeData)`)؛ اگر کاربر سریع دانلود بزند، به‌جای QR متن «در حال بارگذاری...» در پوستر می‌افتد. +3. هیچ await برای اتمام لود شدن ``ها قبل از snapshot وجود ندارد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `components/doctor/detailDoctor/Share.js` | Modal اشتراک‌گذاری؛ Poster را در یک div مخفی (`hiddenRef`) رندر می‌کند | +| `components/doctor/detailDoctor/List.js` | آیکون‌های اشتراک + `handleDownloadPDF` (منطق html2canvas + jsPDF) — **هسته‌ی باگ** | +| `components/doctor/poster/index.js` | چیدمان پوستر | +| `components/doctor/poster/ImageProfile.js` | عکس پروفایل با `next/image` | +| `components/doctor/poster/QrImg.js` | تولید async QR کد | +| `helper/index.js` | `imageUrl(url, fallback)` | + +## وضعیت فعلی + +### `components/doctor/detailDoctor/List.js` (بخش دانلود) + +```jsx +const handleDownloadPDF = async () => { + const element = hiddenRef.current; + if (!element) return; + + const canvas = await html2canvas(element, { + backgroundColor: "#ffffff", + useCORS: true, + scale: 2, + }); + + const imgData = canvas.toDataURL("image/png"); + // ... jsPDF ... + pdf.save("poster.pdf"); +}; +``` + +### `components/doctor/poster/ImageProfile.js` + +```jsx +import Image from "next/image"; +import { imageUrl } from "@/helper"; + +function ImageProfile({ data }) { + return ( +
+ profile-user +
+ ); +} +``` + +### `components/doctor/poster/QrImg.js` (خلاصه) + +```jsx +useEffect(() => { + const info = getStateInfoClient(); + setHost(info.host); + if (info.fullUrl) QRCode.toDataURL(info.fullUrl).then(setQrCodeData); +}, []); +// ... +{qrCodeData ? :

در حال بارگذاری...

} +``` + +## وظایف + +### ۱. منتظر بارگذاری کامل همه‌ی ``ها بمان قبل از `html2canvas` + +در `List.js` یک helper اضافه کن که همه‌ی ``های داخل `element` را پیدا کند و تا `complete` شدن (و در صورت امکان `decode()`) منتظر بماند. سپس در `handleDownloadPDF` قبل از `html2canvas` آن را await کن. + +```jsx +const waitForImages = async (element) => { + const imgs = Array.from(element.querySelectorAll("img")); + await Promise.all( + imgs.map((img) => { + if (img.complete && img.naturalWidth !== 0) { + return img.decode?.().catch(() => {}) ?? Promise.resolve(); + } + return new Promise((resolve) => { + img.onload = () => resolve(); + img.onerror = () => resolve(); // خراب بودن یک عکس نباید کل دانلود را بلاک کند + }); + }) + ); +}; +``` + +در `handleDownloadPDF`: + +```jsx +const element = hiddenRef.current; +if (!element) return; + +await waitForImages(element); + +const canvas = await html2canvas(element, { + backgroundColor: "#ffffff", + useCORS: true, + scale: 2, + imageTimeout: 15000, +}); +``` + +### ۲. اطمینان از آماده بودن QR قبل از دانلود + +اگر QR هنوز تولید نشده باشد، پوستر متن «در حال بارگذاری...» را نشان می‌دهد. راه‌حل ساده و مطمئن: دکمه‌ی دانلود تا آماده شدن QR غیرفعال/در حالت لودینگ باشد. + +- در `QrImg.js` یک callback مثل `onReady` بپذیر و بعد از `setQrCodeData` صدا بزن؛ این وضعیت را تا `Share.js` بالا ببر و به `List.js` بده، یا +- ساده‌تر: در `handleDownloadPDF` بعد از `waitForImages`، اگر داخل `element` عنصری با متن «در حال بارگذاری...» بود، چند صد میلی‌ثانیه polling کوتاه انجام بده تا `img` مربوط به QR ظاهر شود (سقف زمانی بگذار). + +رویکرد اول (state واقعی) تمیزتر است؛ آن را ترجیح بده. + +### ۳. سازگاری عکس پروفایل با html2canvas + +عکس پروفایل با `next/image` از طریق `/_next/image` (same-origin) سرو می‌شود، پس مشکل CORS نباید داشته باشد و **نیازی به تبدیل به `` خام نیست**. اگر بعد از وظیفه‌ی ۱ باز هم عکس پروفایل در خروجی خالی بود: + +- fallback `imageUrl` مقدار `/assets/images/user.png` (local) را برمی‌گرداند که همیشه قابل رندر است — این را نگه‌دار. +- در صورت نیاز `priority` یا `unoptimized` به `` اضافه کن تا lazy load حذف شود و از همان ابتدا لود شود: + +```jsx +profile-user +``` + +> `unoptimized` باعث می‌شود آدرس مستقیم عکس (بدون `/_next/image`) رندر شود؛ اگر دامنه‌ی remote هدر CORS نفرستد ممکن است canvas آلوده شود. اگر این حالت را انتخاب کردی حتماً خروجی را تست کن؛ در غیر این صورت فقط `priority` کافی است. + +### ۴. UX دکمه‌ی دانلود + +- هنگام تولید پوستر، دکمه‌ی «دانلود» را در حالت loading/disabled بگذار تا کلیک دوباره منجر به دانلودهای موازی نشود. +- در صورت throw شدن `html2canvas`، با `toast.error(...)` (فارسی) خطا بده و state را ریست کن. `react-toastify` از قبل import شده. + +## نکات مهم + +- Poster در `Share.js` داخل یک div با `opacity-0 pointer-events-none -z-10` ولی با ابعاد ثابت `600x700` و در `top-0 left-0` رندر می‌شود (یعنی داخل viewport است و لود می‌شود). این ساختار را نگه‌دار — html2canvas به عنصر رندرشده‌ی واقعی نیاز دارد؛ `display:none` نکن. +- نسخه‌ها: `html2canvas@^1.4.1`، `jspdf@^3.0.4`. Tailwind نسخه‌ی ۳ است (رنگ‌ها rgb هستند، مشکل oklch نداریم). +- کلاس‌های `bg-poster` و `bg-stroke-circle` در `globals.css` از تصاویر local (`public/assets/images/...`) به‌عنوان `background-image` استفاده می‌کنند؛ html2canvas background-image را رندر می‌کند و چون local هستند مشکلی ندارند. +- همه‌ی رشته‌های جدید فارسی باشند. +- بعد از تغییر، سناریو را واقعی تست کن: باز کردن Modal، کلیک روی دانلود، و باز کردن PDF خروجی برای تأیید حضور عکس پروفایل + QR + لوگو. diff --git a/components/doctor/detailDoctor/List.js b/components/doctor/detailDoctor/List.js index 9b16283..b1bde9c 100644 --- a/components/doctor/detailDoctor/List.js +++ b/components/doctor/detailDoctor/List.js @@ -6,37 +6,69 @@ import TelegramBlueD from "@/components/icons/TelegramBlueD"; import LinkedingBlueD from "@/components/icons/LinkedingBlueD"; // Packages +import { useState } from "react"; import html2canvas from "html2canvas"; import jsPDF from "jspdf"; import { Button } from "@mui/material"; import { toast } from "react-toastify"; -function List({ hiddenRef, data }) { +const waitForImages = async (element) => { + const imgs = Array.from(element.querySelectorAll("img")); + await Promise.all( + imgs.map((img) => { + if (img.complete && img.naturalWidth !== 0) { + return img.decode?.().catch(() => {}) ?? Promise.resolve(); + } + return new Promise((resolve) => { + img.onload = () => resolve(); + img.onerror = () => resolve(); + }); + }) + ); +}; + +function List({ hiddenRef, data, qrReady }) { + const [downloading, setDownloading] = useState(false); + const handleDownloadPDF = async () => { const element = hiddenRef.current; - if (!element) return; + if (!element || downloading) return; - const canvas = await html2canvas(element, { - backgroundColor: "#ffffff", - useCORS: true, - scale: 2, - }); + if (!qrReady) { + toast.info("در حال آماده‌سازی پوستر، لحظه‌ای صبر کنید..."); + return; + } - const imgData = canvas.toDataURL("image/png"); + setDownloading(true); + try { + await waitForImages(element); - const imgWidth = 210; - const imgProps = canvas; - const pxPerMm = imgProps.width / imgWidth; - const imgHeight = canvas.height / pxPerMm; + const canvas = await html2canvas(element, { + backgroundColor: "#ffffff", + useCORS: true, + scale: 2, + imageTimeout: 15000, + }); - const pdf = new jsPDF({ - orientation: "portrait", - unit: "mm", - format: [imgWidth, imgHeight], - }); + const imgData = canvas.toDataURL("image/png"); - pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight); - pdf.save("poster.pdf"); + const imgWidth = 210; + const pxPerMm = canvas.width / imgWidth; + const imgHeight = canvas.height / pxPerMm; + + const pdf = new jsPDF({ + orientation: "portrait", + unit: "mm", + format: [imgWidth, imgHeight], + }); + + pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight); + pdf.save("poster.pdf"); + } catch { + toast.error("ساخت پوستر ناموفق بود، دوباره تلاش کنید"); + } finally { + setDownloading(false); + } }; const getShareUrl = () => @@ -83,7 +115,12 @@ function List({ hiddenRef, data }) { { name: "تلگرام", icon: , onClick: handleTelegram }, { name: "لینکدین", icon: , onClick: handleLinkedin }, { name: "کپی لینک", icon: , onClick: handleCopyLink }, - { name: "دانلود", icon: , onClick: handleDownloadPDF }, + { + name: "دانلود", + icon: , + onClick: handleDownloadPDF, + disabled: downloading, + }, ]; return ( @@ -103,6 +140,7 @@ function List({ hiddenRef, data }) { onClick={item.onClick} aria-label={item.name} variant="text" + disabled={item.disabled} >
setOpen(true); const handleClose = () => setOpen(false); + const handleQrReady = useCallback(() => setQrReady(true), []); return (
@@ -28,7 +30,7 @@ function Share({ data }) {
- +
@@ -54,7 +56,7 @@ function Share({ data }) {

شما می توانید این پزشک را با دیگران به اشتراک بگذارید:

- +
diff --git a/components/doctor/poster/ImageProfile.js b/components/doctor/poster/ImageProfile.js index 05cf73f..b357779 100644 --- a/components/doctor/poster/ImageProfile.js +++ b/components/doctor/poster/ImageProfile.js @@ -1,16 +1,9 @@ -import Image from "next/image"; -import { imageUrl } from "@/helper"; +import DoctorAvatar from "@/app/component/DoctorAvatar"; function ImageProfile({ data }) { return (
- profile-user +
); } diff --git a/components/doctor/poster/QrImg.js b/components/doctor/poster/QrImg.js index 57a5b94..e14b982 100644 --- a/components/doctor/poster/QrImg.js +++ b/components/doctor/poster/QrImg.js @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import QRCode from "qrcode"; import { getStateInfoClient } from "@/lib/getStateInfoClient"; -export default function QrImg() { +export default function QrImg({ onReady }) { const [host, setHost] = useState(""); const [qrCodeData, setQrCodeData] = useState(""); @@ -14,9 +14,14 @@ export default function QrImg() { setHost(linkHost); if (linkURL) { - QRCode.toDataURL(linkURL).then(setQrCodeData); + QRCode.toDataURL(linkURL).then((url) => { + setQrCodeData(url); + onReady?.(); + }); + } else { + onReady?.(); } - }, []); + }, [onReady]); return (
diff --git a/components/doctor/poster/index.js b/components/doctor/poster/index.js index b182478..63208f1 100644 --- a/components/doctor/poster/index.js +++ b/components/doctor/poster/index.js @@ -1,18 +1,28 @@ +"use client"; +import { useEffect, useState } from "react"; import Image from "next/image"; import Services from "./Services"; import Address from "./Address"; import Telefon from "./Telefon"; import ImageProfile from "./ImageProfile"; import QrImg from "./QrImg"; +import { getStateInfoClient } from "@/lib/getStateInfoClient"; + +function Poster({ data, onQrReady }) { + const [siteName, setSiteName] = useState(""); + + useEffect(() => { + const { matchedCity, host } = getStateInfoClient(); + setSiteName(matchedCity?.domain || host || ""); + }, []); -function Poster({ data }) { return (
logo

- nobat724.com + {siteName}

@@ -31,7 +41,7 @@ function Poster({ data }) {

- +
);