feat: redesign doctor sharing poster layout and enhance download functionality with dynamic file naming

This commit is contained in:
hamed
2026-07-04 22:25:49 +03:30
parent 199c843b08
commit 3c019932bd
9 changed files with 443 additions and 138 deletions
+2 -1
View File
@@ -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 {
+1 -1
View File
@@ -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>
-17
View File
@@ -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;
-11
View File
@@ -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;
+15 -21
View File
@@ -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 }} />
);
}
-23
View File
@@ -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;
-33
View File
@@ -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;
+257 -31
View File
@@ -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>
);
}