feat: redesign doctor sharing poster layout and enhance download functionality with dynamic file naming
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# بازطراحی حرفهای پوستر اشتراکگذاری پزشک (دانلود)
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front`
|
||||
|
||||
## نقش
|
||||
|
||||
بهعنوان متخصص UI/UX عمل کن. خروجی باید یک پوستر برند-محورِ تمیز، مدرن، خوانا و «قابلاشتراک در شبکههای اجتماعی» باشد — نه صرفاً یک باکس اطلاعات.
|
||||
|
||||
## زمینه
|
||||
|
||||
در صفحهی عمومی پزشک (`/doctor/[slug]`) دکمهی «اشتراک گذاری» یک Modal باز میکند و گزینهی «دانلود» یک پوستر PDF از پزشک میسازد (via `html2canvas` + `jsPDF`). پوستر فعلی ساده و نامتوازن است: چیدمان دو ستونهی خام، تایپوگرافی بدون سلسلهمراتب، بدون slogan/امتیاز/شبکههای اجتماعی، لوگو خیلی کوچک، و ابعاد ثابت `600×700` که با محتوای متغیر (لیست خدمات/آدرس بلند) سرریز/نامرتب میشود.
|
||||
|
||||
مکانیزم آمادهسازی تصویر قبلاً درست شده: `List.js` قبل از `html2canvas` منتظر لود همهی `<img>`ها و آماده شدن 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
|
||||
<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">{siteName}</p>
|
||||
</div>
|
||||
<p className="mt-[40px] ... text-[20px] font-bold">{data?.name}</p>
|
||||
<p className="text-[24px] mt-[20px] ...">تخصص: {/* specialties */}</p>
|
||||
<Services data={data} />
|
||||
<Address data={data} />
|
||||
<Telefon data={data} />
|
||||
</div>
|
||||
<div className="flex flex-col justify-between items-end min-w-[200px]">
|
||||
<ImageProfile data={data} />
|
||||
<QrImg onReady={onQrReady} />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
`components/doctor/detailDoctor/Share.js` (کانتینر مخفی — ابعاد ثابت فعلی):
|
||||
|
||||
```jsx
|
||||
<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} onQrReady={handleQrReady} />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
`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";
|
||||
// ...
|
||||
<header className="flex items-center gap-3">
|
||||
<img src={logoSrc} alt="logo" className="h-14 w-auto" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[24px] font-bold text-white">{matchedCity?.site_name}</span>
|
||||
<span className="text-[14px] text-[#B9D2EC]">{matchedCity?.domain}</span>
|
||||
</div>
|
||||
</header>
|
||||
```
|
||||
|
||||
> نکته html2canvas: اگر `logo_url` از دامنهی remote بیاید و آن سرور هدر CORS نفرستد، ممکن است در canvas آلوده/خالی شود. برای remote از `<img crossOrigin="anonymous">` استفاده کن و اگر لود نشد به لوگوی 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`). برای المانهای تزئینی میتوانی از `<img>` ساده استفاده کنی.
|
||||
- خروجی نهایی را **واقعی تست کن**: باز کردن Modal → دانلود → باز کردن PDF و بررسی: لوگو واضح، عکس پزشک، QR، خدمات/آدرس/تلفن مرتب، بدون سرریز، و چیدمان متوازن برای پزشکی با دادهی کم و پزشکی با دادهی زیاد (edge case).
|
||||
- RTL: چیدمان و alignment راستبهچپ درست باشد؛ شمارهها `ltr`.
|
||||
- کیفیت: متن نباید تار شود — کانواس ثابت + `scale ≥ 1.5`.
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,7 +28,7 @@ function Share({ data }) {
|
||||
<ShareDI />
|
||||
<p className="hidden lg:flex">اشتراک گذاری</p>
|
||||
</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 className="fixed w-[1080px] h-[1350px] top-0 left-0 opacity-0 pointer-events-none -z-10">
|
||||
<div ref={hiddenRef}>
|
||||
<Poster data={data} onQrReady={handleQrReady} />
|
||||
</div>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import ClipBoard from "../../icons/ClipBoard";
|
||||
|
||||
function Address({ data }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start mt-[40px]">
|
||||
<div className="flex items-center justify-start gap-[6px]">
|
||||
<ClipBoard />
|
||||
<p className="text-[#E7EEF6] text-[20px] font-semibold">آدرس:</p>
|
||||
</div>
|
||||
<p className="text-[#E7EEF6] text-[16px] !font-normal mt-[16px] text-right">
|
||||
{data?.address?.[0]?.address}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Address;
|
||||
@@ -1,11 +0,0 @@
|
||||
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">
|
||||
<DoctorAvatar doctor={data} size={173} priority className="rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageProfile;
|
||||
@@ -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 (
|
||||
<div className="flex items-center flex-col justify-center mt-[100%]">
|
||||
{qrCodeData ? (
|
||||
<img src={qrCodeData} width={167} height={167} alt="کد QR پروفایل پزشک" />
|
||||
) : (
|
||||
<p>در حال بارگذاری...</p>
|
||||
)}
|
||||
<p className="text-[#3987D4] min-h-[24px] font-semibold text-[16px] mt-[32px]">
|
||||
{host || ""}
|
||||
</p>
|
||||
</div>
|
||||
return qrCodeData ? (
|
||||
<img
|
||||
src={qrCodeData}
|
||||
width={size}
|
||||
height={size}
|
||||
alt="کد QR پروفایل پزشک"
|
||||
style={{ width: size, height: size, display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: size, height: size }} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import ClipBoard from "../../icons/ClipBoard";
|
||||
import CircleOrangeSm from "../../icons/CircleOrangeSm";
|
||||
|
||||
function Services({ data }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start mt-[40px]">
|
||||
<div className="flex items-center justify-start gap-[6px]">
|
||||
<ClipBoard />
|
||||
<p className="text-[#E7EEF6] text-[20px] font-semibold">خدمات:</p>
|
||||
</div>
|
||||
<ul className="flex flex-col justify-start items-start gap-[16px] mt-[16px] font-normal ">
|
||||
{data?.expertise?.map((item, idx) => (
|
||||
<li key={idx} className="flex items-center justify-start gap-[4px]">
|
||||
<CircleOrangeSm />
|
||||
<p className="text-[16px] font-normal text-[#E7EEF6]">{item.name}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Services;
|
||||
@@ -1,33 +0,0 @@
|
||||
import TelefonPoster from "@/components/icons/TelefonPoster";
|
||||
|
||||
function Telefon({ data }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex-col items-start justify-start mt-[40px] ${data?.address?.[0]?.phone ? "flex" : "hidden"}`}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-[6px]">
|
||||
<TelefonPoster />
|
||||
<p className="text-[#E7EEF6] text-[20px] font-semibold">تلفن:</p>
|
||||
</div>
|
||||
<p className="text-[#E7EEF6] text-[16px] font-normal mt-[16px]">
|
||||
{data?.address?.[0]?.phone}
|
||||
</p>
|
||||
{data?.address &&
|
||||
data?.address.length > 1 &&
|
||||
data?.address.map(
|
||||
(item, idx) =>
|
||||
idx !== 0 && (
|
||||
<span
|
||||
key={idx}
|
||||
dir="ltr"
|
||||
className="text-[#E7EEF6] text-[16px] w-fit font-normal mt-[12px] inline"
|
||||
>
|
||||
{item?.phone} -
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Telefon;
|
||||
@@ -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 (
|
||||
<img
|
||||
src={city.logo_url}
|
||||
alt="logo"
|
||||
crossOrigin="anonymous"
|
||||
className={`object-contain ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M50 12 A38 38 0 1 0 88 50 A19 19 0 0 0 55.6 36.6"
|
||||
fill="none"
|
||||
stroke="#5559CE"
|
||||
strokeWidth="7.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect x="46.5" y="40" width="7" height="20" rx="3.5" fill="#FF8848" />
|
||||
<rect x="40" y="46.5" width="20" height="7" rx="3.5" fill="#FF8848" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ icon, children }) {
|
||||
return (
|
||||
<div className="mb-[20px] text-right">
|
||||
<span className="inline-grid place-items-center align-middle w-[40px] h-[40px] rounded-[12px] bg-white/12 ml-[12px]">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="align-middle text-white text-[26px] font-bold leading-none">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 && <Instagram key="ig" />,
|
||||
social.telegram && <Telegram key="tg" />,
|
||||
social.whatsapp && <Whatsapp key="wa" />,
|
||||
].filter(Boolean);
|
||||
|
||||
const badges = [
|
||||
Number(data?.experience) > 0 && {
|
||||
icon: <span className="align-middle text-[#F6B93B] text-[20px] ml-[8px]">◆</span>,
|
||||
label: `${data.experience} سال تجربه`,
|
||||
},
|
||||
(data?.point ?? 0) > 0 && {
|
||||
icon: <span className="align-middle inline-block ml-[8px]"><StarDI /></span>,
|
||||
label: `امتیاز ${data.point}`,
|
||||
},
|
||||
(data?.satisfaction ?? 0) > 0 && {
|
||||
icon: <span className="align-middle text-[#4ADE80] text-[20px] ml-[8px]">♥</span>,
|
||||
label: `${data.satisfaction}% رضایت`,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
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">
|
||||
{siteName}
|
||||
</p>
|
||||
<div
|
||||
dir="rtl"
|
||||
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
|
||||
style={{ background: POSTER_BG, direction: "rtl" }}
|
||||
>
|
||||
<div className="pointer-events-none absolute -top-[180px] -left-[180px] w-[520px] h-[520px] rounded-full bg-[#57A6FF]/20 blur-[80px]" />
|
||||
<div className="pointer-events-none absolute bottom-[120px] -right-[160px] w-[460px] h-[460px] rounded-full bg-[#0B2A4A]/40 blur-[80px]" />
|
||||
|
||||
{/* header — float layout so RTL survives html2canvas */}
|
||||
<header className="relative overflow-hidden">
|
||||
<div className="float-right">
|
||||
<SiteLogo city={city} className="inline-block align-middle h-[64px] w-[64px] ml-[16px]" />
|
||||
<div className="inline-block align-middle">
|
||||
<div className="text-white text-[30px] font-extrabold leading-[36px]">
|
||||
{city?.site_name || "نوبت ۷۲۴"}
|
||||
</div>
|
||||
<div className="text-[#9FC1E6] text-[18px] font-medium leading-[22px]" dir="ltr">
|
||||
{city?.domain || "nobat724.com"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-[40px] text-[#E7EEF6] text-[20px] font-bold text-right">
|
||||
{data?.name}
|
||||
<div className="float-left rounded-full bg-[#F59E0B] text-[#1A1200] text-[22px] font-bold px-[24px] h-[56px] leading-[56px] text-center">
|
||||
نوبتدهی آنلاین
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{city?.slogan && (
|
||||
<p className="relative mt-[24px] text-[#C7DBF2] text-[22px] font-medium leading-[1.6]">
|
||||
{city.slogan}
|
||||
</p>
|
||||
<p className="text-[24px] mt-[20px] text-[#E7EEF6] font-bold text-right">
|
||||
تخصص:
|
||||
{data?.specialties?.map(
|
||||
(item, idx) =>
|
||||
`${item.name} ${data.specialties.length === idx + 1 ? "" : "|"} `
|
||||
)}
|
||||
|
||||
{/* hero */}
|
||||
<section className="relative mt-[40px] rounded-[32px] bg-white/8 border border-white/12 p-[40px] overflow-hidden">
|
||||
<div className="float-right rounded-full p-[8px] ml-[36px] bg-gradient-to-br from-[#5AA6FF] to-[#F59E0B]">
|
||||
<div className="rounded-full p-[6px] bg-[#0B2A4A]">
|
||||
<DoctorAvatar
|
||||
doctor={data}
|
||||
size={220}
|
||||
priority
|
||||
className="rounded-full !object-cover w-[220px] h-[220px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<h1 className="text-white text-[46px] font-extrabold leading-[1.3]">
|
||||
{data?.name}
|
||||
</h1>
|
||||
<div className="mt-[14px]">
|
||||
{specialties.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 text-[#FCD9A0] text-[22px] font-semibold px-[18px] h-[46px] leading-[46px] ml-[10px] mb-[10px]"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-[6px]">
|
||||
{badges.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block rounded-full bg-white/10 border border-white/15 px-[18px] h-[52px] leading-[52px] ml-[12px] mb-[12px]"
|
||||
>
|
||||
{b.icon}
|
||||
<span className="align-middle text-[#EAF2FB] text-[22px] font-semibold">
|
||||
{b.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{data?.medical_system_code && (
|
||||
<p className="mt-[8px] text-[#9FC1E6] text-[20px] leading-[1.5]">
|
||||
کد نظام پزشکی:{" "}
|
||||
<span dir="ltr" className="font-semibold text-[#E7EEF6]">
|
||||
{data.medical_system_code}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</p>
|
||||
<Services data={data} />
|
||||
<Address data={data} />
|
||||
<Telefon data={data} />
|
||||
</div>
|
||||
<div className="flex flex-col justify-between items-end min-w-[200px]">
|
||||
<ImageProfile data={data} />
|
||||
<QrImg onReady={onQrReady} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* body */}
|
||||
<section className="relative mt-[36px] flex-1">
|
||||
{shownServices.length > 0 && (
|
||||
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px] mb-[28px]">
|
||||
<SectionTitle icon={<ClipBoard />}>خدمات</SectionTitle>
|
||||
<div className="text-right">
|
||||
{shownServices.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block rounded-full bg-white/8 border border-white/10 px-[18px] h-[48px] leading-[48px] ml-[12px] mb-[14px]"
|
||||
>
|
||||
<span className="align-middle inline-block w-[10px] h-[10px] rounded-full bg-[#F59E0B] ml-[10px]" />
|
||||
<span className="align-middle text-[#E7EEF6] text-[22px] font-normal">
|
||||
{s.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{moreServices > 0 && (
|
||||
<span className="inline-block align-middle text-[#9FC1E6] text-[22px] h-[48px] leading-[48px]">
|
||||
+ {moreServices} خدمت دیگر
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(address || phones.length > 0) && (
|
||||
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px]">
|
||||
{address && (
|
||||
<div className="mb-[24px]">
|
||||
<SectionTitle icon={<AddressPoster />}>آدرس</SectionTitle>
|
||||
<p className="text-[#E7EEF6] text-[23px] font-normal leading-[1.7] line-clamp-2 text-right">
|
||||
{address}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{phones.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle icon={<TelefonPoster />}>تلفن</SectionTitle>
|
||||
<div className="text-right">
|
||||
{phones.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
dir="ltr"
|
||||
className="inline-block rounded-[14px] bg-white/10 text-[#E7EEF6] text-[24px] font-semibold px-[20px] h-[56px] leading-[56px] ml-[16px]"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* footer */}
|
||||
<footer className="relative mt-[36px] rounded-[28px] bg-white/8 border border-white/12 p-[32px] overflow-hidden">
|
||||
<div className="float-right">
|
||||
<div className="inline-block align-middle rounded-[20px] bg-white p-[16px] ml-[24px]">
|
||||
<QrImg onReady={onQrReady} size={168} />
|
||||
</div>
|
||||
<div className="inline-block align-middle">
|
||||
<div className="text-white text-[28px] font-bold leading-[34px]">
|
||||
برای رزرو نوبت اسکن کنید
|
||||
</div>
|
||||
<div className="text-[#9FC1E6] text-[22px] leading-[28px] mt-[6px]" dir="ltr">
|
||||
{city?.domain || "nobat724.com"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="float-left text-left">
|
||||
<SiteLogo city={city} className="inline-block h-[48px] w-[48px]" />
|
||||
{socialItems.length > 0 && (
|
||||
<div className="mt-[14px]">
|
||||
{socialItems.map((icon, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-grid place-items-center align-middle w-[44px] h-[44px] rounded-full bg-white ml-[10px]"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user