feat: enhance poster download functionality by ensuring images load before PDF generation and adding QR readiness check
This commit is contained in:
@@ -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` (`<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 برای اتمام لود شدن `<img>`ها قبل از 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 (
|
||||
<div className="bg-stroke-circle ml-[10px] mt-[57px] p-[9px] rounded-full overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl(data?.img?.[0]?.url)}
|
||||
width={173}
|
||||
height={173}
|
||||
alt="profile-user"
|
||||
className="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### `components/doctor/poster/QrImg.js` (خلاصه)
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
const info = getStateInfoClient();
|
||||
setHost(info.host);
|
||||
if (info.fullUrl) QRCode.toDataURL(info.fullUrl).then(setQrCodeData);
|
||||
}, []);
|
||||
// ...
|
||||
{qrCodeData ? <img src={qrCodeData} .../> : <p>در حال بارگذاری...</p>}
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. منتظر بارگذاری کامل همهی `<img>`ها بمان قبل از `html2canvas`
|
||||
|
||||
در `List.js` یک helper اضافه کن که همهی `<img>`های داخل `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 نباید داشته باشد و **نیازی به تبدیل به `<img>` خام نیست**. اگر بعد از وظیفهی ۱ باز هم عکس پروفایل در خروجی خالی بود:
|
||||
|
||||
- fallback `imageUrl` مقدار `/assets/images/user.png` (local) را برمیگرداند که همیشه قابل رندر است — این را نگهدار.
|
||||
- در صورت نیاز `priority` یا `unoptimized` به `<Image>` اضافه کن تا lazy load حذف شود و از همان ابتدا لود شود:
|
||||
|
||||
```jsx
|
||||
<Image
|
||||
src={imageUrl(data?.img?.[0]?.url)}
|
||||
width={173}
|
||||
height={173}
|
||||
alt="profile-user"
|
||||
className="rounded-full"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
```
|
||||
|
||||
> `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 + لوگو.
|
||||
@@ -6,27 +6,54 @@ 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;
|
||||
|
||||
if (!qrReady) {
|
||||
toast.info("در حال آمادهسازی پوستر، لحظهای صبر کنید...");
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloading(true);
|
||||
try {
|
||||
await waitForImages(element);
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
backgroundColor: "#ffffff",
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
imageTimeout: 15000,
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL("image/png");
|
||||
|
||||
const imgWidth = 210;
|
||||
const imgProps = canvas;
|
||||
const pxPerMm = imgProps.width / imgWidth;
|
||||
const pxPerMm = canvas.width / imgWidth;
|
||||
const imgHeight = canvas.height / pxPerMm;
|
||||
|
||||
const pdf = new jsPDF({
|
||||
@@ -37,6 +64,11 @@ function List({ hiddenRef, data }) {
|
||||
|
||||
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: <TelegramBlueD />, onClick: handleTelegram },
|
||||
{ name: "لینکدین", icon: <LinkedingBlueD />, onClick: handleLinkedin },
|
||||
{ name: "کپی لینک", icon: <CopyBlueD />, onClick: handleCopyLink },
|
||||
{ name: "دانلود", icon: <DownloadBlue />, onClick: handleDownloadPDF },
|
||||
{
|
||||
name: "دانلود",
|
||||
icon: <DownloadBlue />,
|
||||
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}
|
||||
>
|
||||
<div
|
||||
className="grid place-items-center w-full-icon
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { styleDefault } from "@/mui";
|
||||
import ShareDI from "@/components/icons/ShareDI";
|
||||
@@ -11,9 +11,11 @@ import Poster from "../poster";
|
||||
function Share({ data }) {
|
||||
const hiddenRef = useRef();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [qrReady, setQrReady] = useState(false);
|
||||
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
const handleQrReady = useCallback(() => setQrReady(true), []);
|
||||
|
||||
return (
|
||||
<div className="w-full text-left">
|
||||
@@ -28,7 +30,7 @@ function Share({ data }) {
|
||||
</Button>
|
||||
<div className="fixed min-w-[600px] min-h-[700px] w-[600px] h-[700px] top-0 left-0 opacity-0 pointer-events-none -z-10">
|
||||
<div ref={hiddenRef}>
|
||||
<Poster data={data} />
|
||||
<Poster data={data} onQrReady={handleQrReady} />
|
||||
</div>
|
||||
</div>
|
||||
<Modal open={open} onClose={handleClose}>
|
||||
@@ -54,7 +56,7 @@ function Share({ data }) {
|
||||
<p className="text-[#525252] text-[11px] md:text-[12px] lg:text-[14px] font-normal">
|
||||
شما می توانید این پزشک را با دیگران به اشتراک بگذارید:
|
||||
</p>
|
||||
<List hiddenRef={hiddenRef} data={data} />
|
||||
<List hiddenRef={hiddenRef} data={data} qrReady={qrReady} />
|
||||
</Box>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import Image from "next/image";
|
||||
import { imageUrl } from "@/helper";
|
||||
import DoctorAvatar from "@/app/component/DoctorAvatar";
|
||||
|
||||
function ImageProfile({ data }) {
|
||||
return (
|
||||
<div className="bg-stroke-circle ml-[10px] mt-[57px] p-[9px] rounded-full overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl(data?.img?.[0]?.url)}
|
||||
width={173}
|
||||
height={173}
|
||||
alt="profile-user"
|
||||
className="rounded-full"
|
||||
/>
|
||||
<DoctorAvatar doctor={data} size={173} priority className="rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center flex-col justify-center mt-[100%]">
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-poster overflow-hidden !pt-[32px] p-[24px] relative flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center justify-start gap-[5px]">
|
||||
<Image src="/assets/images/logo.png" width={30} height={30} alt="logo" />
|
||||
<p className="text-[#E7EEF6] text-[16px] font-bold !-translate-y-1">
|
||||
nobat724.com
|
||||
{siteName}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-[40px] text-[#E7EEF6] text-[20px] font-bold text-right">
|
||||
@@ -31,7 +41,7 @@ function Poster({ data }) {
|
||||
</div>
|
||||
<div className="flex flex-col justify-between items-end min-w-[200px]">
|
||||
<ImageProfile data={data} />
|
||||
<QrImg />
|
||||
<QrImg onReady={onQrReady} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user