feat: integrate html-to-image library for enhanced image generation and QR code rendering
This commit is contained in:
@@ -7,11 +7,38 @@ import LinkedingBlueD from "@/components/icons/LinkedingBlueD";
|
||||
|
||||
// Packages
|
||||
import { useState } from "react";
|
||||
import { toPng } from "html-to-image";
|
||||
import html2canvas from "html2canvas";
|
||||
import jsPDF from "jspdf";
|
||||
import { Button } from "@mui/material";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const VAZIR_WEIGHTS = [
|
||||
[400, "Vazirmatn-RD-FD-Regular.ttf"],
|
||||
[500, "Vazirmatn-RD-FD-Medium.ttf"],
|
||||
[600, "Vazirmatn-RD-FD-SemiBold.ttf"],
|
||||
[700, "Vazirmatn-RD-FD-Bold.ttf"],
|
||||
[800, "Vazirmatn-RD-FD-ExtraBold.ttf"],
|
||||
];
|
||||
|
||||
let vazirEmbedCssPromise;
|
||||
const getVazirEmbedCss = () => {
|
||||
vazirEmbedCssPromise ??= Promise.all(
|
||||
VAZIR_WEIGHTS.map(async ([weight, file]) => {
|
||||
const res = await fetch(`/fonts/vazir/${file}`);
|
||||
const blob = await res.blob();
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
return `@font-face{font-family:vazir;src:url(${dataUrl});font-weight:${weight};}`;
|
||||
})
|
||||
).then((faces) => faces.join("\n"));
|
||||
return vazirEmbedCssPromise;
|
||||
};
|
||||
|
||||
const waitForImages = async (element) => {
|
||||
const imgs = Array.from(element.querySelectorAll("img"));
|
||||
await Promise.all(
|
||||
@@ -43,18 +70,35 @@ function List({ hiddenRef, data, qrReady }) {
|
||||
try {
|
||||
await waitForImages(element);
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
backgroundColor: "#ffffff",
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
imageTimeout: 15000,
|
||||
});
|
||||
let imgData;
|
||||
let pxWidth;
|
||||
let pxHeight;
|
||||
|
||||
const imgData = canvas.toDataURL("image/png");
|
||||
try {
|
||||
const fontEmbedCSS = await getVazirEmbedCss();
|
||||
imgData = await toPng(element, {
|
||||
pixelRatio: 2,
|
||||
backgroundColor: "#ffffff",
|
||||
style: { opacity: "1" },
|
||||
fontEmbedCSS,
|
||||
});
|
||||
pxWidth = element.offsetWidth * 2;
|
||||
pxHeight = element.offsetHeight * 2;
|
||||
} catch {
|
||||
const canvas = await html2canvas(element, {
|
||||
backgroundColor: "#ffffff",
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
imageTimeout: 15000,
|
||||
});
|
||||
imgData = canvas.toDataURL("image/png");
|
||||
pxWidth = canvas.width;
|
||||
pxHeight = canvas.height;
|
||||
}
|
||||
|
||||
const imgWidth = 210;
|
||||
const pxPerMm = canvas.width / imgWidth;
|
||||
const imgHeight = canvas.height / pxPerMm;
|
||||
const pxPerMm = pxWidth / imgWidth;
|
||||
const imgHeight = pxHeight / pxPerMm;
|
||||
|
||||
const pdf = new jsPDF({
|
||||
orientation: "portrait",
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRef, useEffect } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
||||
|
||||
export default function QrImg({ onReady, size = 168 }) {
|
||||
const [qrCodeData, setQrCodeData] = useState("");
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { fullUrl } = getStateInfoClient();
|
||||
|
||||
if (fullUrl) {
|
||||
QRCode.toDataURL(fullUrl, { margin: 1, width: size * 2 }).then((url) => {
|
||||
setQrCodeData(url);
|
||||
onReady?.();
|
||||
});
|
||||
if (fullUrl && canvasRef.current) {
|
||||
QRCode.toCanvas(
|
||||
canvasRef.current,
|
||||
fullUrl,
|
||||
{ width: size, margin: 1, color: { dark: "#0B2A4A", light: "#ffffff" } },
|
||||
() => onReady?.()
|
||||
);
|
||||
} else {
|
||||
onReady?.();
|
||||
}
|
||||
}, [onReady, size]);
|
||||
|
||||
return qrCodeData ? (
|
||||
<img
|
||||
src={qrCodeData}
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={size}
|
||||
height={size}
|
||||
alt="کد QR پروفایل پزشک"
|
||||
style={{ width: size, height: size, display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: size, height: size }} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function SiteLogo({ city, className }) {
|
||||
<path
|
||||
d="M50 12 A38 38 0 1 0 88 50 A19 19 0 0 0 55.6 36.6"
|
||||
fill="none"
|
||||
stroke="#5559CE"
|
||||
stroke="#8FB6FF"
|
||||
strokeWidth="7.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
@@ -47,13 +47,11 @@ function SiteLogo({ city, className }) {
|
||||
|
||||
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]">
|
||||
<div className="flex items-center gap-[12px] mb-[20px]">
|
||||
<span className="flex items-center justify-center w-[40px] h-[40px] rounded-[12px] bg-white/12">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="align-middle text-white text-[26px] font-bold leading-none">
|
||||
{children}
|
||||
</span>
|
||||
<span className="text-white text-[26px] font-bold">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,15 +83,15 @@ function Poster({ data, onQrReady }) {
|
||||
|
||||
const badges = [
|
||||
Number(data?.experience) > 0 && {
|
||||
icon: <span className="align-middle text-[#F6B93B] text-[20px] ml-[8px]">◆</span>,
|
||||
icon: <span className="text-[#F6B93B] text-[20px] leading-none">◆</span>,
|
||||
label: `${data.experience} سال تجربه`,
|
||||
},
|
||||
(data?.point ?? 0) > 0 && {
|
||||
icon: <span className="align-middle inline-block ml-[8px]"><StarDI /></span>,
|
||||
icon: <StarDI />,
|
||||
label: `امتیاز ${data.point}`,
|
||||
},
|
||||
(data?.satisfaction ?? 0) > 0 && {
|
||||
icon: <span className="align-middle text-[#4ADE80] text-[20px] ml-[8px]">♥</span>,
|
||||
icon: <span className="text-[#4ADE80] text-[20px] leading-none">♥</span>,
|
||||
label: `${data.satisfaction}% رضایت`,
|
||||
},
|
||||
].filter(Boolean);
|
||||
@@ -102,38 +100,40 @@ function Poster({ data, onQrReady }) {
|
||||
<div
|
||||
dir="rtl"
|
||||
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right"
|
||||
style={{ background: POSTER_BG, direction: "rtl" }}
|
||||
style={{ background: POSTER_BG }}
|
||||
>
|
||||
<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]">
|
||||
{/* header */}
|
||||
<header className="relative flex items-center justify-between">
|
||||
<div className="flex items-center gap-[16px]">
|
||||
<SiteLogo city={city} className="h-[64px] w-[64px]" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-[30px] font-extrabold leading-tight">
|
||||
{city?.site_name || "نوبت ۷۲۴"}
|
||||
</div>
|
||||
<div className="text-[#9FC1E6] text-[18px] font-medium leading-[22px]" dir="ltr">
|
||||
</span>
|
||||
<span className="text-[#9FC1E6] text-[18px] font-medium" dir="ltr">
|
||||
{city?.domain || "nobat724.com"}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="float-left rounded-full bg-[#F59E0B] text-[#1A1200] text-[22px] font-bold px-[24px] h-[56px] leading-[56px] text-center">
|
||||
نوبتدهی آنلاین
|
||||
<div className="flex items-center rounded-full bg-[#F59E0B] px-[28px] py-[14px]">
|
||||
<span className="text-[#1A1200] text-[22px] font-bold leading-none">
|
||||
نوبتدهی آنلاین
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{city?.slogan && (
|
||||
<p className="relative mt-[24px] text-[#C7DBF2] text-[22px] font-medium leading-[1.6]">
|
||||
<p className="relative mt-[20px] text-[#C7DBF2] text-[22px] font-medium">
|
||||
{city.slogan}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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]">
|
||||
<section className="relative mt-[40px] rounded-[32px] bg-white/8 border border-white/12 p-[40px] flex items-center gap-[36px]">
|
||||
<div className="shrink-0 rounded-full p-[8px] bg-gradient-to-br from-[#5AA6FF] to-[#F59E0B]">
|
||||
<div className="rounded-full p-[6px] bg-[#0B2A4A]">
|
||||
<DoctorAvatar
|
||||
doctor={data}
|
||||
@@ -143,35 +143,37 @@ function Poster({ data, onQrReady }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<h1 className="text-white text-[46px] font-extrabold leading-[1.3]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-white text-[46px] font-extrabold leading-tight">
|
||||
{data?.name}
|
||||
</h1>
|
||||
<div className="mt-[14px]">
|
||||
<div className="flex flex-wrap gap-[10px] mt-[16px]">
|
||||
{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]"
|
||||
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
|
||||
>
|
||||
{s.name}
|
||||
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
|
||||
{s.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-[6px]">
|
||||
<div className="flex flex-wrap gap-[12px] mt-[18px]">
|
||||
{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]"
|
||||
className="flex items-center gap-[8px] rounded-full bg-white/10 border border-white/15 px-[18px] py-[11px]"
|
||||
>
|
||||
{b.icon}
|
||||
<span className="align-middle text-[#EAF2FB] text-[22px] font-semibold">
|
||||
<span className="text-[#EAF2FB] text-[22px] font-semibold leading-none whitespace-nowrap">
|
||||
{b.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{data?.medical_system_code && (
|
||||
<p className="mt-[8px] text-[#9FC1E6] text-[20px] leading-[1.5]">
|
||||
<p className="mt-[16px] text-[#9FC1E6] text-[20px]">
|
||||
کد نظام پزشکی:{" "}
|
||||
<span dir="ltr" className="font-semibold text-[#E7EEF6]">
|
||||
{data.medical_system_code}
|
||||
@@ -186,20 +188,20 @@ function Poster({ data, onQrReady }) {
|
||||
{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">
|
||||
<div className="flex flex-wrap items-center gap-x-[12px] gap-y-[14px]">
|
||||
{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]"
|
||||
className="flex items-center gap-[10px] rounded-full bg-white/8 border border-white/10 px-[18px] py-[10px]"
|
||||
>
|
||||
<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">
|
||||
<span className="w-[10px] h-[10px] rounded-full bg-[#F59E0B] shrink-0" />
|
||||
<span className="text-[#E7EEF6] text-[22px] font-normal leading-none whitespace-nowrap">
|
||||
{s.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{moreServices > 0 && (
|
||||
<span className="inline-block align-middle text-[#9FC1E6] text-[22px] h-[48px] leading-[48px]">
|
||||
<span className="text-[#9FC1E6] text-[22px]">
|
||||
+ {moreServices} خدمت دیگر
|
||||
</span>
|
||||
)}
|
||||
@@ -212,7 +214,7 @@ function Poster({ data, onQrReady }) {
|
||||
{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">
|
||||
<p className="text-[#E7EEF6] text-[23px] font-normal leading-[1.7] line-clamp-2">
|
||||
{address}
|
||||
</p>
|
||||
</div>
|
||||
@@ -220,14 +222,16 @@ function Poster({ data, onQrReady }) {
|
||||
{phones.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle icon={<TelefonPoster />}>تلفن</SectionTitle>
|
||||
<div className="text-right">
|
||||
<div className="flex flex-wrap gap-[16px]">
|
||||
{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]"
|
||||
className="flex items-center rounded-[14px] bg-white/10 px-[20px] py-[12px]"
|
||||
>
|
||||
{p}
|
||||
<span className="text-[#E7EEF6] text-[24px] font-semibold leading-none">
|
||||
{p}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -238,29 +242,29 @@ function Poster({ data, onQrReady }) {
|
||||
</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]">
|
||||
<footer className="relative mt-[36px] rounded-[28px] bg-white/8 border border-white/12 p-[32px] flex items-center justify-between">
|
||||
<div className="flex items-center gap-[24px]">
|
||||
<div className="rounded-[20px] bg-white p-[16px]">
|
||||
<QrImg onReady={onQrReady} size={168} />
|
||||
</div>
|
||||
<div className="inline-block align-middle">
|
||||
<div className="text-white text-[28px] font-bold leading-[34px]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-[28px] font-bold">
|
||||
برای رزرو نوبت اسکن کنید
|
||||
</div>
|
||||
<div className="text-[#9FC1E6] text-[22px] leading-[28px] mt-[6px]" dir="ltr">
|
||||
</span>
|
||||
<span className="text-[#9FC1E6] text-[22px] mt-[8px]" dir="ltr">
|
||||
{city?.domain || "nobat724.com"}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="float-left text-left">
|
||||
<SiteLogo city={city} className="inline-block h-[48px] w-[48px]" />
|
||||
<div className="flex flex-col items-end gap-[14px]">
|
||||
<SiteLogo city={city} className="h-[48px] w-[48px]" />
|
||||
{socialItems.length > 0 && (
|
||||
<div className="mt-[14px]">
|
||||
<div className="flex items-center gap-[10px]">
|
||||
{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]"
|
||||
className="flex items-center justify-center w-[44px] h-[44px] rounded-full bg-white"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
Generated
+7
@@ -24,6 +24,7 @@
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i": "^0.3.7",
|
||||
"isomorphic-dompurify": "^3.18.0",
|
||||
@@ -3002,6 +3003,12 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i": "^0.3.7",
|
||||
"isomorphic-dompurify": "^3.18.0",
|
||||
|
||||
Reference in New Issue
Block a user