diff --git a/.claude/prompt/doctor-poster-redesign.md b/.claude/prompt/doctor-poster-redesign.md new file mode 100644 index 0000000..a75a70d --- /dev/null +++ b/.claude/prompt/doctor-poster-redesign.md @@ -0,0 +1,168 @@ +# بازطراحی حرفه‌ای پوستر اشتراک‌گذاری پزشک (دانلود) + +## پروژه + +`nobat724_front` + +## نقش + +به‌عنوان متخصص UI/UX عمل کن. خروجی باید یک پوستر برند-محورِ تمیز، مدرن، خوانا و «قابل‌اشتراک در شبکه‌های اجتماعی» باشد — نه صرفاً یک باکس اطلاعات. + +## زمینه + +در صفحه‌ی عمومی پزشک (`/doctor/[slug]`) دکمه‌ی «اشتراک گذاری» یک Modal باز می‌کند و گزینه‌ی «دانلود» یک پوستر PDF از پزشک می‌سازد (via `html2canvas` + `jsPDF`). پوستر فعلی ساده و نامتوازن است: چیدمان دو ستونه‌ی خام، تایپوگرافی بدون سلسله‌مراتب، بدون slogan/امتیاز/شبکه‌های اجتماعی، لوگو خیلی کوچک، و ابعاد ثابت `600×700` که با محتوای متغیر (لیست خدمات/آدرس بلند) سرریز/نامرتب می‌شود. + +مکانیزم آماده‌سازی تصویر قبلاً درست شده: `List.js` قبل از `html2canvas` منتظر لود همه‌ی ``ها و آماده شدن QR می‌ماند، و عکس پروفایل از `DoctorAvatar` مشترک با صفحه می‌آید. **این بازطراحی فقط لایه‌ی بصری/چیدمان پوستر است؛ منطق دانلود و انتظار تصویر را نشکن.** + +## هدف + +پوستر را به بهترین شکل ممکن بازطراحی کن با این الزامات کاربر: +1. **لوگوی سایت حتماً حاضر و واضح باشد** (نه لوگوی ۳۰px گوشه) — در هدر به‌صورت برند، و به‌صورت واترمارک ظریف در فوتر. +2. کیفیت دانلود بالا و خروجی «شبکه‌اجتماعی‌پسند». +3. RTL، فونت Vazir، هماهنگ با هویت بصری سایت. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `components/doctor/poster/index.js` | چیدمان اصلی پوستر — بازطراحی کامل | +| `components/doctor/poster/ImageProfile.js` | عکس پزشک (DoctorAvatar، ring) | +| `components/doctor/poster/QrImg.js` | QR + دامنه؛ `onReady` را نگه‌دار | +| `components/doctor/poster/Services.js` | لیست خدمات (`data.expertise`) | +| `components/doctor/poster/Address.js` | آدرس (`data.address[0].address`) | +| `components/doctor/poster/Telefon.js` | تلفن‌ها (`data.address[*].phone`) | +| `components/doctor/detailDoctor/Share.js` | کانتینر مخفی `hiddenRef` (ابعاد ثابت) + Modal | +| `components/doctor/detailDoctor/List.js` | `handleDownloadPDF` (html2canvas + jsPDF) — منطق را نگه‌دار، فقط ابعاد/نام فایل | +| `lib/getStateInfoClient.js` | `matchedCity` (شامل `logo_url`, `site_name`, `slogan`, `social_media`, `domain`, `contact_phone`) | +| `app/globals.css` | کلاس `.bg-poster` (خط ۸۳۸) و `.bg-stroke-circle` (خط ۸۴۶) | +| `data/city.json` | فیلدهای هر شهر: `logo_url`, `site_name`, `slogan`, `social_media`, `domain` | +| `public/assets/images/` | `logo.png`, `logo-2.png`, `poster.png`, `stroke-circle.png` | + +## وضعیت فعلی + +`components/doctor/poster/index.js` (چیدمان فعلی خام): + +```jsx +
+
+
+ logo +

{siteName}

+
+

{data?.name}

+

تخصص: {/* specialties */}

+ +
+ +
+
+ + +
+
+``` + +`components/doctor/detailDoctor/Share.js` (کانتینر مخفی — ابعاد ثابت فعلی): + +```jsx +
+
+ +
+
+``` + +`components/doctor/detailDoctor/List.js` (دانلود — این منطق را نگه‌دار): + +```jsx +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 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"); +``` + +## داده‌های در دسترس + +- پزشک (`data`): `name`, `specialties[].name`, `expertise[].name` (خدمات), `address[].address`, `address[].phone`, `experience`, `point` (امتیاز), `satisfaction`, `medical_system_code`, `img[0].url`, `social_media`. +- شهر (`getStateInfoClient().matchedCity`): `logo_url`, `site_name`, `slogan`, `domain`, `contact_phone`, `social_media` (instagram/telegram/whatsapp). +- QR: از `getStateInfoClient().fullUrl` (لینک همین صفحه). + +## وظایف + +### ۱. کانواس ثابت و باکیفیت + +ابعاد پوستر را به یک کانواس ثابت با نسبت مناسب شبکه‌های اجتماعی ببر تا خروجی همیشه مرتب و پرکیفیت باشد. پیشنهاد: **نسبت ۴:۵ (مثلاً `1080×1350`)** یا استوری `1080×1920`. نسبت ۴:۵ برای پست اینستاگرام بهینه است. + +- در `Share.js` ابعاد کانتینر مخفی را به همان کانواس تغییر بده (مثلاً `w-[1080px] h-[1350px]`). چون این عنصر مخفی و خارج از viewport رندر می‌شود، سایز بزرگ مشکلی ندارد. +- در `List.js` می‌توانی `scale` را روی `2` نگه داری (خروجی ۲۱۶۰×۲۷۰۰). اگر سایز کانواس را بزرگ کردی و خروجی سنگین شد، `scale: 1.5` هم قابل قبول است. نام فایل را معنادار کن: `` `poster-${data?.name ?? 'doctor'}.pdf` `` (کاراکترهای غیرمجاز را با `-` جایگزین کن). + +### ۲. سیستم بصری (به‌عنوان UI/UX) + +یک زبان بصری منسجم بساز؛ همه‌ی مقادیر زیر پیشنهادی‌اند، خودت به‌عنوان طراح تنظیم کن: + +- **پس‌زمینه:** گرادیان برند (آبی تیره → آبی، هم‌خانواده با رنگ فعلی `#3987D4`/`#E7EEF6`). می‌توانی `.bg-poster` (تصویر `poster.png`) را نگه داری یا با گرادیان CSS جایگزین کنی. اگر گرادیان می‌سازی، از رنگ‌های rgb/hex استفاده کن (Tailwind v3، بدون oklch) تا `html2canvas` درست رندر کند. +- **کارت‌های شیشه‌ای/بخش‌بندی:** بخش خدمات/آدرس/تلفن را داخل کارت‌های نیمه‌شفاف با گوشه‌ی گرد و فاصله‌گذاری یکنواخت بگذار تا سلسله‌مراتب دیده شود. +- **تایپوگرافی:** سلسله‌مراتب واضح — نام پزشک بزرگ‌ترین (bold)، تخصص متوسط، برچسب بخش‌ها (خدمات/آدرس/تلفن) کوچک‌تر با آیکون. فونت فقط Vazir (از قبل در پروژه). +- **رنگ تأکید:** نارنجی موجود (`CircleOrangeSm`) برای بولت‌ها/CTA حفظ شود. +- **آواتار:** عکس دایره‌ای با حلقه‌ی ظریف (`.bg-stroke-circle` فعلی یا ring با border)، سایز بزرگ‌تر و به‌عنوان کانون بصری بالای پوستر. + +### ۳. هدر برند + لوگو (الزام کاربر) + +هدر شامل: +- **لوگوی سایت واضح**: از `matchedCity?.logo_url` استفاده کن؛ اگر `null` بود fallback به `/assets/images/logo.png` (یا `logo-2.png` هرکدام مناسب‌تر). اندازه‌ی محسوس (مثلاً ارتفاع ۴۸–۶۴px)، نه ۳۰px. +- `site_name` شهر + `domain` کنارش. +- در صورت وجود `slogan`، یک خط زیر لوگو. + +مثال (pseudocode): + +```jsx +const logoSrc = matchedCity?.logo_url || "/assets/images/logo.png"; +// ... +
+ logo +
+ {matchedCity?.site_name} + {matchedCity?.domain} +
+
+``` + +> نکته html2canvas: اگر `logo_url` از دامنه‌ی remote بیاید و آن سرور هدر CORS نفرستد، ممکن است در canvas آلوده/خالی شود. برای remote از `` استفاده کن و اگر لود نشد به لوگوی local برگرد (onError → fallback). لوگوی local همیشه امن است. + +### ۴. بدنه — بخش‌های اطلاعاتی با مدیریت سرریز + +- **هویت پزشک:** نام + برچسب تخصص‌ها به‌صورت chipهای کوچک (به‌جای متن پیوسته با `|`). ردیف badgeها: سابقه (`experience` سال)، امتیاز (`point` با آیکون ستاره)، رضایت (`satisfaction%`)، کد نظام (`medical_system_code`) — هرکدام موجود بود. +- **خدمات (`expertise`):** حداکثر N مورد (مثلاً ۶) نشان بده؛ اگر بیشتر بود «+X مورد دیگر». از overflow جلوگیری کن (کانواس ثابت است، محتوای متغیر نباید بشکند). +- **آدرس:** یک آدرس اصلی؛ متن بلند را با `line-clamp` (۲ خط) محدود کن. +- **تلفن:** شماره‌ی اصلی؛ اگر چند شماره بود حداکثر ۲ مورد. `dir="ltr"` برای شماره‌ها. +- همه‌ی این‌ها را طوری بچین که در ارتفاع ثابت کانواس جا شود؛ فضای خالی را با فاصله‌گذاری متوازن پر کن، نه با کشیدن یک بخش. + +### ۵. فوتر — QR + CTA + واترمارک لوگو + +- بلوک QR (همان `QrImg`) با یک برچسب CTA: «برای رزرو نوبت اسکن کنید» و دامنه زیر آن. +- آیکون/آی‌دی شبکه‌های اجتماعی از `matchedCity?.social_media` (اینستاگرام/تلگرام/واتساپ) اگر موجود بود. +- **واترمارک لوگو**: نسخه‌ی کم‌رنگ لوگو در گوشه/پس‌زمینه‌ی فوتر برای تأکید برند (الزام «لوگوی سایتم باید باشد»). +- `QrImg` باید `onReady` را همچنان صدا بزند (منطق انتظار دانلود در `List.js` به آن وابسته است) — این prop و رفتارش را نگه‌دار. + +### ۶. حفظ سازگاری با پایپ‌لاین دانلود + +- `List.js`: `waitForImages`, `imageTimeout`, `useCORS`, و گارد `qrReady` را نگه‌دار؛ فقط ابعاد format و نام فایل را در صورت نیاز به‌روزرسانی کن. +- `Share.js`: کانتینر باید همچنان **رندر‌شده ولی مخفی** بماند (`opacity-0 pointer-events-none -z-10`, نه `display:none`) تا تصاویر لود شوند و html2canvas بتواند snapshot بگیرد. +- `Poster` همچنان props `{ data, onQrReady }` را بگیرد. + +## نکات مهم + +- Tailwind v3 است (رنگ‌ها rgb/hex، بدون oklch) — از رنگ‌های مدرن غیرقابل‌رندر در html2canvas پرهیز کن. +- فونت فقط **Vazir**؛ فونت جدید اضافه نکن. مطمئن شو وزن‌های استفاده‌شده در `globals.css` تعریف شده‌اند (font-display swap) تا در snapshot درست بیایند. +- همه‌ی assetها **local** ترجیح داده شوند؛ برای remote (مثل `logo_url`) از `crossOrigin="anonymous"` + fallback استفاده کن. +- تصاویر `next/image` در پوستر: ترجیحاً `priority` بده تا lazy-load نشوند (مثل `ImageProfile`). برای المان‌های تزئینی می‌توانی از `` ساده استفاده کنی. +- خروجی نهایی را **واقعی تست کن**: باز کردن Modal → دانلود → باز کردن PDF و بررسی: لوگو واضح، عکس پزشک، QR، خدمات/آدرس/تلفن مرتب، بدون سرریز، و چیدمان متوازن برای پزشکی با داده‌ی کم و پزشکی با داده‌ی زیاد (edge case). +- RTL: چیدمان و alignment راست‌به‌چپ درست باشد؛ شماره‌ها `ltr`. +- کیفیت: متن نباید تار شود — کانواس ثابت + `scale ≥ 1.5`. diff --git a/components/doctor/detailDoctor/List.js b/components/doctor/detailDoctor/List.js index b1bde9c..031d1f5 100644 --- a/components/doctor/detailDoctor/List.js +++ b/components/doctor/detailDoctor/List.js @@ -63,7 +63,8 @@ function List({ hiddenRef, data, qrReady }) { }); pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight); - pdf.save("poster.pdf"); + const safeName = (data?.name ?? "doctor").replace(/[\\/:*?"<>|]+/g, "-"); + pdf.save(`poster-${safeName}.pdf`); } catch { toast.error("ساخت پوستر ناموفق بود، دوباره تلاش کنید"); } finally { diff --git a/components/doctor/detailDoctor/Share.js b/components/doctor/detailDoctor/Share.js index a91d230..ac5ed98 100644 --- a/components/doctor/detailDoctor/Share.js +++ b/components/doctor/detailDoctor/Share.js @@ -28,7 +28,7 @@ function Share({ data }) {

اشتراک گذاری

-
+
diff --git a/components/doctor/poster/Address.js b/components/doctor/poster/Address.js deleted file mode 100644 index c064278..0000000 --- a/components/doctor/poster/Address.js +++ /dev/null @@ -1,17 +0,0 @@ -import ClipBoard from "../../icons/ClipBoard"; - -function Address({ data }) { - return ( -
-
- -

آدرس:

-
-

- {data?.address?.[0]?.address} -

-
- ); -} - -export default Address; diff --git a/components/doctor/poster/ImageProfile.js b/components/doctor/poster/ImageProfile.js deleted file mode 100644 index b357779..0000000 --- a/components/doctor/poster/ImageProfile.js +++ /dev/null @@ -1,11 +0,0 @@ -import DoctorAvatar from "@/app/component/DoctorAvatar"; - -function ImageProfile({ data }) { - return ( -
- -
- ); -} - -export default ImageProfile; diff --git a/components/doctor/poster/QrImg.js b/components/doctor/poster/QrImg.js index e14b982..2f774e3 100644 --- a/components/doctor/poster/QrImg.js +++ b/components/doctor/poster/QrImg.js @@ -2,37 +2,31 @@ import { useState, useEffect } from "react"; import QRCode from "qrcode"; import { getStateInfoClient } from "@/lib/getStateInfoClient"; -export default function QrImg({ onReady }) { - const [host, setHost] = useState(""); +export default function QrImg({ onReady, size = 168 }) { const [qrCodeData, setQrCodeData] = useState(""); useEffect(() => { - const info = getStateInfoClient(); - const linkHost = info.host; - const linkURL = info.fullUrl; + const { fullUrl } = getStateInfoClient(); - setHost(linkHost); - - if (linkURL) { - QRCode.toDataURL(linkURL).then((url) => { + if (fullUrl) { + QRCode.toDataURL(fullUrl, { margin: 1, width: size * 2 }).then((url) => { setQrCodeData(url); onReady?.(); }); } else { onReady?.(); } - }, [onReady]); + }, [onReady, size]); - return ( -
- {qrCodeData ? ( - کد QR پروفایل پزشک - ) : ( -

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

- )} -

- {host || ""} -

-
+ return qrCodeData ? ( + کد QR پروفایل پزشک + ) : ( +
); } diff --git a/components/doctor/poster/Services.js b/components/doctor/poster/Services.js deleted file mode 100644 index 1769090..0000000 --- a/components/doctor/poster/Services.js +++ /dev/null @@ -1,23 +0,0 @@ -import ClipBoard from "../../icons/ClipBoard"; -import CircleOrangeSm from "../../icons/CircleOrangeSm"; - -function Services({ data }) { - return ( -
-
- -

خدمات:

-
-
    - {data?.expertise?.map((item, idx) => ( -
  • - -

    {item.name}

    -
  • - ))} -
-
- ); -} - -export default Services; diff --git a/components/doctor/poster/Telefon.js b/components/doctor/poster/Telefon.js deleted file mode 100644 index 6d38e4c..0000000 --- a/components/doctor/poster/Telefon.js +++ /dev/null @@ -1,33 +0,0 @@ -import TelefonPoster from "@/components/icons/TelefonPoster"; - -function Telefon({ data }) { - return ( -
-
- -

تلفن:

-
-

- {data?.address?.[0]?.phone} -

- {data?.address && - data?.address.length > 1 && - data?.address.map( - (item, idx) => - idx !== 0 && ( - - {item?.phone} - - - ) - )} -
- ); -} - -export default Telefon; diff --git a/components/doctor/poster/index.js b/components/doctor/poster/index.js index 63208f1..9bca41c 100644 --- a/components/doctor/poster/index.js +++ b/components/doctor/poster/index.js @@ -1,48 +1,274 @@ "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 DoctorAvatar from "@/app/component/DoctorAvatar"; import QrImg from "./QrImg"; import { getStateInfoClient } from "@/lib/getStateInfoClient"; +import StarDI from "@/components/icons/StarDI"; +import ClipBoard from "@/components/icons/ClipBoard"; +import AddressPoster from "@/components/icons/AddressPoster"; +import TelefonPoster from "@/components/icons/TelefonPoster"; +import Instagram from "@/components/icons/Instagram"; +import Telegram from "@/components/icons/Telegram"; +import Whatsapp from "@/components/icons/Whatsapp"; + +const POSTER_BG = + "linear-gradient(160deg, #0B2A4A 0%, #16528C 52%, #2E77C4 100%)"; + +function SiteLogo({ city, className }) { + if (city?.logo_url) { + return ( + logo + ); + } + return ( + + + + + + ); +} + +function SectionTitle({ icon, children }) { + return ( +
+ + {icon} + + + {children} + +
+ ); +} + function Poster({ data, onQrReady }) { - const [siteName, setSiteName] = useState(""); + const [city, setCity] = useState(null); useEffect(() => { - const { matchedCity, host } = getStateInfoClient(); - setSiteName(matchedCity?.domain || host || ""); + const { matchedCity } = getStateInfoClient(); + setCity(matchedCity || null); }, []); + const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4); + const services = (data?.expertise ?? []).filter((s) => s?.name); + const shownServices = services.slice(0, 8); + const moreServices = services.length - shownServices.length; + const address = data?.address?.[0]?.address; + const phones = (data?.address ?? []) + .map((a) => a?.phone) + .filter(Boolean) + .slice(0, 2); + + const social = city?.social_media ?? {}; + const socialItems = [ + social.instagram && , + social.telegram && , + social.whatsapp && , + ].filter(Boolean); + + const badges = [ + Number(data?.experience) > 0 && { + icon: , + label: `${data.experience} سال تجربه`, + }, + (data?.point ?? 0) > 0 && { + icon: , + label: `امتیاز ${data.point}`, + }, + (data?.satisfaction ?? 0) > 0 && { + icon: , + label: `${data.satisfaction}% رضایت`, + }, + ].filter(Boolean); + return ( -
-
-
- logo -

- {siteName} -

+
+
+
+ + {/* header — float layout so RTL survives html2canvas */} +
+
+ +
+
+ {city?.site_name || "نوبت ۷۲۴"} +
+
+ {city?.domain || "nobat724.com"} +
+
-

- {data?.name} +

+ نوبت‌دهی آنلاین +
+
+ + {city?.slogan && ( +

+ {city.slogan}

-

- تخصص: - {data?.specialties?.map( - (item, idx) => - `${item.name} ${data.specialties.length === idx + 1 ? "" : "|"} ` + )} + + {/* hero */} +

+
+
+ +
+
+
+

+ {data?.name} +

+
+ {specialties.map((s, i) => ( + + {s.name} + + ))} +
+
+ {badges.map((b, i) => ( + + {b.icon} + + {b.label} + + + ))} +
+ {data?.medical_system_code && ( +

+ کد نظام پزشکی:{" "} + + {data.medical_system_code} + +

)} -

- -
- -
-
- - -
+
+ + + {/* body */} +
+ {shownServices.length > 0 && ( +
+ }>خدمات +
+ {shownServices.map((s, i) => ( + + + + {s.name} + + + ))} + {moreServices > 0 && ( + + + {moreServices} خدمت دیگر + + )} +
+
+ )} + + {(address || phones.length > 0) && ( +
+ {address && ( +
+ }>آدرس +

+ {address} +

+
+ )} + {phones.length > 0 && ( +
+ }>تلفن +
+ {phones.map((p, i) => ( + + {p} + + ))} +
+
+ )} +
+ )} +
+ + {/* footer */} +
); }