581 lines
16 KiB
JavaScript
581 lines
16 KiB
JavaScript
/**
|
|
* Client-side invoice PDF: نوع محصول · عدد · قیمت · عکس
|
|
* Site palette + KikoMiko wordmark linked to kikomiko.ir
|
|
*/
|
|
|
|
const PALETTE = {
|
|
ink: "#22191a",
|
|
background: "#fffcfb",
|
|
soft: "#f8f1ef",
|
|
blush: "#f4e4e0",
|
|
berry: "#9a3d4f",
|
|
muted: "#8a7874",
|
|
gold: "#c4a07a",
|
|
cardBorder: "#eaddd8",
|
|
white: "#ffffff",
|
|
};
|
|
|
|
const SITE_URL = "https://kikomiko.ir/";
|
|
const SITE_HOST = "kikomiko.ir";
|
|
|
|
function formatFaInt(n) {
|
|
return new Intl.NumberFormat("fa-IR", { maximumFractionDigits: 0 }).format(n);
|
|
}
|
|
|
|
function loadImage(src) {
|
|
return new Promise((resolve) => {
|
|
if (!src) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
const img = new Image();
|
|
img.decoding = "async";
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => resolve(null);
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
function wrapText(ctx, text, maxWidth) {
|
|
const words = String(text || "").split(/\s+/).filter(Boolean);
|
|
if (!words.length) return [""];
|
|
const lines = [];
|
|
let line = words[0];
|
|
for (let i = 1; i < words.length; i++) {
|
|
const test = `${line} ${words[i]}`;
|
|
if (ctx.measureText(test).width <= maxWidth) line = test;
|
|
else {
|
|
lines.push(line);
|
|
line = words[i];
|
|
}
|
|
}
|
|
lines.push(line);
|
|
return lines;
|
|
}
|
|
|
|
async function ensureInvoiceFonts() {
|
|
try {
|
|
if (document.fonts?.load) {
|
|
await Promise.all([
|
|
document.fonts.load('700 28px "Yekan Bakh"'),
|
|
document.fonts.load('300 28px "Yekan Bakh"'),
|
|
document.fonts.load('400 13px "Yekan Bakh"'),
|
|
]);
|
|
}
|
|
} catch {
|
|
/* fallback fonts below */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw site wordmark (Kiko bold berry + Miko light ink) + kikomiko.ir under it.
|
|
* @returns {{ x: number; y: number; w: number; h: number }} hit area for PDF link
|
|
*/
|
|
function drawBrandLogo(ctx, fontFamily, rightX, topY) {
|
|
const mark = "Kiko";
|
|
const tail = "Miko";
|
|
ctx.save();
|
|
ctx.direction = "ltr";
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "top";
|
|
|
|
ctx.font = `700 26px ${fontFamily}`;
|
|
const markW = ctx.measureText(mark).width;
|
|
ctx.font = `300 26px ${fontFamily}`;
|
|
const tailW = ctx.measureText(tail).width;
|
|
const logoW = markW + tailW;
|
|
const logoX = rightX - logoW;
|
|
|
|
ctx.font = `700 26px ${fontFamily}`;
|
|
ctx.fillStyle = PALETTE.berry;
|
|
ctx.fillText(mark, logoX, topY);
|
|
|
|
ctx.font = `300 26px ${fontFamily}`;
|
|
ctx.fillStyle = PALETTE.ink;
|
|
ctx.fillText(tail, logoX + markW, topY);
|
|
|
|
ctx.font = `500 11px ${fontFamily}`;
|
|
ctx.fillStyle = PALETTE.berry;
|
|
const urlW = ctx.measureText(SITE_HOST).width;
|
|
ctx.fillText(SITE_HOST, rightX - urlW, topY + 32);
|
|
|
|
ctx.strokeStyle = PALETTE.gold;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(rightX - urlW, topY + 45);
|
|
ctx.lineTo(rightX, topY + 45);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
return {
|
|
x: Math.min(logoX, rightX - urlW),
|
|
y: topY,
|
|
w: Math.max(logoW, urlW),
|
|
h: 48,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Embed JPEG page images into a PDF (A4). Optional link rects in JPEG pixel space (top-left origin).
|
|
* @param {Array<{ bytes: Uint8Array; w: number; h: number; links?: Array<{ x: number; y: number; w: number; h: number; url: string }> }>} pages
|
|
*/
|
|
function jpegsToPdfBlob(pages) {
|
|
const pageW = 595.28;
|
|
const pageH = 841.89;
|
|
const margin = 18;
|
|
const encoder = new TextEncoder();
|
|
const objects = [];
|
|
|
|
function addObj(body) {
|
|
objects.push(body);
|
|
return objects.length;
|
|
}
|
|
|
|
function pdfEscape(s) {
|
|
return String(s).replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
}
|
|
|
|
const fontId = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>`);
|
|
const pageIds = [];
|
|
|
|
for (const page of pages) {
|
|
const maxW = pageW - margin * 2;
|
|
const maxH = pageH - margin * 2;
|
|
const fit = Math.min(maxW / page.w, maxH / page.h, 1);
|
|
const dw = page.w * fit;
|
|
const dh = page.h * fit;
|
|
const x = (pageW - dw) / 2;
|
|
const y = pageH - margin - dh;
|
|
|
|
const annotIds = [];
|
|
for (const link of page.links || []) {
|
|
const sx = dw / page.w;
|
|
const sy = dh / page.h;
|
|
const left = x + link.x * sx;
|
|
const bottom = y + (page.h - link.y - link.h) * sy;
|
|
const right = left + link.w * sx;
|
|
const top = bottom + link.h * sy;
|
|
const annotId = addObj(
|
|
`<< /Type /Annot /Subtype /Link /Rect [${left.toFixed(2)} ${bottom.toFixed(2)} ${right.toFixed(2)} ${top.toFixed(2)}] /Border [0 0 0] /A << /S /URI /URI (${pdfEscape(link.url)}) >> >>`
|
|
);
|
|
annotIds.push(annotId);
|
|
}
|
|
|
|
const imgId = addObj("IMG");
|
|
const contentStream = `q\n${dw.toFixed(2)} 0 0 ${dh.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm\n/Im0 Do\nQ\n`;
|
|
const contentId = addObj(
|
|
`<< /Length ${encoder.encode(contentStream).length} >>\nstream\n${contentStream}\nendstream`
|
|
);
|
|
const annots =
|
|
annotIds.length > 0
|
|
? ` /Annots [${annotIds.map((id) => `${id} 0 R`).join(" ")}]`
|
|
: "";
|
|
const pageId = addObj(
|
|
`<< /Type /Page /Parent 0 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentId} 0 R /Resources << /XObject << /Im0 ${imgId} 0 R >> /Font << /F1 ${fontId} 0 R >> >>${annots} >>`
|
|
);
|
|
pageIds.push({ pageId, imgId, jpeg: page.bytes, imgW: page.w, imgH: page.h });
|
|
}
|
|
|
|
const kids = pageIds.map((p) => `${p.pageId} 0 R`).join(" ");
|
|
const pagesId = addObj(`<< /Type /Pages /Kids [${kids}] /Count ${pageIds.length} >>`);
|
|
const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
|
|
|
|
for (const p of pageIds) {
|
|
objects[p.pageId - 1] = objects[p.pageId - 1].replace(
|
|
"/Parent 0 0 R",
|
|
`/Parent ${pagesId} 0 R`
|
|
);
|
|
}
|
|
|
|
const imgIdSet = new Map(pageIds.map((p) => [p.imgId, p]));
|
|
const parts = [];
|
|
const offsets = [0];
|
|
let offset = 0;
|
|
|
|
function pushStr(s) {
|
|
const b = typeof s === "string" ? encoder.encode(s) : s;
|
|
parts.push(b);
|
|
offset += b.length;
|
|
}
|
|
|
|
pushStr("%PDF-1.4\n");
|
|
|
|
for (let i = 0; i < objects.length; i++) {
|
|
offsets.push(offset);
|
|
const n = i + 1;
|
|
const imgPage = imgIdSet.get(n);
|
|
if (imgPage) {
|
|
const jpeg = imgPage.jpeg;
|
|
pushStr(`${n} 0 obj\n`);
|
|
pushStr(
|
|
`<< /Type /XObject /Subtype /Image /Width ${imgPage.imgW} /Height ${imgPage.imgH} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n`
|
|
);
|
|
parts.push(jpeg);
|
|
offset += jpeg.length;
|
|
pushStr("\nendstream\nendobj\n");
|
|
} else {
|
|
pushStr(`${n} 0 obj\n${objects[i]}\nendobj\n`);
|
|
}
|
|
}
|
|
|
|
const xrefStart = offset;
|
|
pushStr(`xref\n0 ${objects.length + 1}\n`);
|
|
pushStr("0000000000 65535 f \n");
|
|
for (let i = 1; i <= objects.length; i++) {
|
|
pushStr(`${String(offsets[i]).padStart(10, "0")} 00000 n \n`);
|
|
}
|
|
pushStr(
|
|
`trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`
|
|
);
|
|
|
|
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
const out = new Uint8Array(total);
|
|
let o = 0;
|
|
for (const p of parts) {
|
|
out.set(p, o);
|
|
o += p.length;
|
|
}
|
|
return new Blob([out], { type: "application/pdf" });
|
|
}
|
|
|
|
function canvasToJpegBytes(canvas, quality = 0.92) {
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
async (blob) => {
|
|
if (!blob) {
|
|
reject(new Error("JPEG encode failed"));
|
|
return;
|
|
}
|
|
resolve(new Uint8Array(await blob.arrayBuffer()));
|
|
},
|
|
"image/jpeg",
|
|
quality
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* orderId: string;
|
|
* dateLabel: string;
|
|
* customerName: string;
|
|
* customerPhone?: string;
|
|
* address?: string;
|
|
* city?: string;
|
|
* province?: string;
|
|
* items: Array<{ name: string; category?: string|null; quantity: number; price: number; image?: string|null }>;
|
|
* totalLabel: string;
|
|
* formatPrice: (n: number) => string;
|
|
* eurToTomanRate?: number;
|
|
* }} data
|
|
* @returns {Promise<File>}
|
|
*/
|
|
export async function generateInvoicePdf(data) {
|
|
await ensureInvoiceFonts();
|
|
const images = await Promise.all(data.items.map((i) => loadImage(i.image)));
|
|
|
|
const W = 794;
|
|
const pad = 36;
|
|
const thumbW = 72;
|
|
const thumbH = 96;
|
|
const colQty = 64;
|
|
const colPrice = 130;
|
|
const colImg = 92;
|
|
const colType = W - pad * 2 - colQty - colPrice - colImg;
|
|
const rowPad = 10;
|
|
const font = '"Yekan Bakh",Tahoma,"Segoe UI",Arial,sans-serif';
|
|
|
|
const measure = document.createElement("canvas").getContext("2d");
|
|
if (!measure) throw new Error("Canvas unavailable");
|
|
measure.font = `12px ${font}`;
|
|
|
|
const rowHeights = data.items.map((item) => {
|
|
const type = item.category || "—";
|
|
const nameLines = wrapText(measure, item.name || "", colType - 16);
|
|
const typeLines = wrapText(measure, type, colType - 16);
|
|
const textH = (typeLines.length + nameLines.length) * 16 + 8;
|
|
return Math.max(thumbH + rowPad * 2, textH + rowPad * 2);
|
|
});
|
|
|
|
const headerBandH = 64;
|
|
const infoBlockEstimate = 70;
|
|
const tableHeadH = 36;
|
|
const footerH = 88;
|
|
const tableH = rowHeights.reduce((a, b) => a + b, 0);
|
|
const H = pad + headerBandH + 16 + infoBlockEstimate + tableHeadH + tableH + footerH;
|
|
|
|
const scale = 2;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = W * scale;
|
|
canvas.height = H * scale;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Canvas unavailable");
|
|
ctx.scale(scale, scale);
|
|
|
|
ctx.fillStyle = PALETTE.background;
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.textBaseline = "top";
|
|
ctx.direction = "rtl";
|
|
|
|
let y = 0;
|
|
|
|
ctx.fillStyle = PALETTE.soft;
|
|
ctx.fillRect(0, 0, W, headerBandH);
|
|
ctx.fillStyle = PALETTE.berry;
|
|
ctx.fillRect(0, headerBandH - 3, W, 3);
|
|
|
|
y = 14;
|
|
const logoHit = drawBrandLogo(ctx, font, W - pad, y);
|
|
|
|
ctx.textAlign = "left";
|
|
ctx.direction = "ltr";
|
|
ctx.fillStyle = PALETTE.ink;
|
|
ctx.font = `700 12px ${font}`;
|
|
ctx.fillText(data.orderId, pad, y + 8);
|
|
ctx.font = `400 11px ${font}`;
|
|
ctx.fillStyle = PALETTE.muted;
|
|
ctx.fillText(data.dateLabel, pad, y + 28);
|
|
ctx.direction = "rtl";
|
|
|
|
y = headerBandH + 18;
|
|
|
|
ctx.fillStyle = PALETTE.ink;
|
|
ctx.font = `400 13px ${font}`;
|
|
ctx.textAlign = "right";
|
|
const info = [
|
|
`نام مشتری: ${data.customerName}`,
|
|
data.customerPhone ? `تلفن: ${data.customerPhone}` : "",
|
|
data.address
|
|
? `آدرس: ${data.address}${data.city ? ` — ${data.city}` : ""}${data.province ? `، ${data.province}` : ""}`
|
|
: "",
|
|
].filter(Boolean);
|
|
for (const line of info) {
|
|
ctx.fillText(line, W - pad, y);
|
|
y += 20;
|
|
}
|
|
y += 10;
|
|
|
|
const cols = [
|
|
{ key: "type", w: colType, label: "نوع محصول" },
|
|
{ key: "qty", w: colQty, label: "عدد" },
|
|
{ key: "price", w: colPrice, label: "قیمت" },
|
|
{ key: "img", w: colImg, label: "عکس" },
|
|
];
|
|
function colXs() {
|
|
let right = W - pad;
|
|
return cols.map((c) => {
|
|
const x0 = right - c.w;
|
|
right = x0;
|
|
return { ...c, x: x0 };
|
|
});
|
|
}
|
|
|
|
const headerCols = colXs();
|
|
ctx.fillStyle = PALETTE.blush;
|
|
ctx.fillRect(pad, y, W - pad * 2, tableHeadH);
|
|
ctx.strokeStyle = PALETTE.cardBorder;
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(pad, y, W - pad * 2, tableHeadH);
|
|
ctx.fillStyle = PALETTE.berry;
|
|
ctx.font = `700 12px ${font}`;
|
|
for (const c of headerCols) {
|
|
ctx.strokeRect(c.x, y, c.w, tableHeadH);
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(c.label, c.x + c.w / 2, y + 11);
|
|
}
|
|
y += tableHeadH;
|
|
|
|
for (let i = 0; i < data.items.length; i++) {
|
|
const item = data.items[i];
|
|
const rh = rowHeights[i];
|
|
const rowCols = colXs();
|
|
|
|
ctx.fillStyle = i % 2 === 0 ? PALETTE.white : PALETTE.soft;
|
|
ctx.fillRect(pad, y, W - pad * 2, rh);
|
|
ctx.strokeStyle = PALETTE.cardBorder;
|
|
ctx.strokeRect(pad, y, W - pad * 2, rh);
|
|
for (const c of rowCols) ctx.strokeRect(c.x, y, c.w, rh);
|
|
|
|
const typeCol = rowCols.find((c) => c.key === "type");
|
|
const qtyCol = rowCols.find((c) => c.key === "qty");
|
|
const priceCol = rowCols.find((c) => c.key === "price");
|
|
const imgCol = rowCols.find((c) => c.key === "img");
|
|
|
|
ctx.fillStyle = PALETTE.ink;
|
|
ctx.font = `700 12px ${font}`;
|
|
ctx.textAlign = "right";
|
|
const type = item.category || "—";
|
|
let ty = y + rowPad;
|
|
for (const line of wrapText(ctx, type, colType - 16)) {
|
|
ctx.fillText(line, typeCol.x + typeCol.w - 8, ty);
|
|
ty += 16;
|
|
}
|
|
ctx.font = `400 11px ${font}`;
|
|
ctx.fillStyle = PALETTE.muted;
|
|
for (const line of wrapText(ctx, item.name || "", colType - 16)) {
|
|
ctx.fillText(line, typeCol.x + typeCol.w - 8, ty);
|
|
ty += 15;
|
|
}
|
|
|
|
ctx.fillStyle = PALETTE.ink;
|
|
ctx.font = `500 13px ${font}`;
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(formatFaInt(item.quantity), qtyCol.x + qtyCol.w / 2, y + rh / 2 - 7);
|
|
ctx.fillText(
|
|
data.formatPrice(item.price * item.quantity),
|
|
priceCol.x + priceCol.w / 2,
|
|
y + rh / 2 - 7
|
|
);
|
|
|
|
const img = images[i];
|
|
const ix = imgCol.x + (imgCol.w - thumbW) / 2;
|
|
const iy = y + (rh - thumbH) / 2;
|
|
if (img) {
|
|
ctx.drawImage(img, ix, iy, thumbW, thumbH);
|
|
ctx.strokeStyle = PALETTE.cardBorder;
|
|
ctx.strokeRect(ix, iy, thumbW, thumbH);
|
|
} else {
|
|
ctx.fillStyle = PALETTE.blush;
|
|
ctx.fillRect(ix, iy, thumbW, thumbH);
|
|
}
|
|
|
|
y += rh;
|
|
}
|
|
|
|
y += 14;
|
|
ctx.strokeStyle = PALETTE.gold;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pad, y);
|
|
ctx.lineTo(W - pad, y);
|
|
ctx.stroke();
|
|
y += 14;
|
|
|
|
ctx.fillStyle = PALETTE.berry;
|
|
ctx.font = `700 15px ${font}`;
|
|
ctx.textAlign = "right";
|
|
ctx.fillText("جمع کل", W - pad, y);
|
|
ctx.textAlign = "left";
|
|
ctx.direction = "ltr";
|
|
ctx.fillText(data.totalLabel, pad, y);
|
|
ctx.direction = "rtl";
|
|
|
|
const eurRate =
|
|
typeof data.eurToTomanRate === "number" &&
|
|
Number.isFinite(data.eurToTomanRate) &&
|
|
data.eurToTomanRate > 0
|
|
? data.eurToTomanRate
|
|
: 0;
|
|
if (eurRate > 0) {
|
|
y += 26;
|
|
ctx.fillStyle = PALETTE.muted;
|
|
ctx.font = `400 12px ${font}`;
|
|
ctx.textAlign = "right";
|
|
const rateLabel = `نرخ یورو: ${data.formatPrice(eurRate)}`;
|
|
ctx.fillText(rateLabel, W - pad, y);
|
|
}
|
|
|
|
const jpegFull = await canvasToJpegBytes(canvas, 0.92);
|
|
const pageCssH = Math.floor((W * 841.89) / 595.28);
|
|
const pages = [];
|
|
const logoLinkJpeg = {
|
|
x: logoHit.x * scale,
|
|
y: logoHit.y * scale,
|
|
w: logoHit.w * scale,
|
|
h: logoHit.h * scale,
|
|
url: SITE_URL,
|
|
};
|
|
|
|
if (H <= pageCssH + 40) {
|
|
pages.push({
|
|
bytes: jpegFull,
|
|
w: canvas.width,
|
|
h: canvas.height,
|
|
links: [logoLinkJpeg],
|
|
});
|
|
} else {
|
|
let sy = 0;
|
|
let pageIndex = 0;
|
|
while (sy < H) {
|
|
const sliceH = Math.min(pageCssH, H - sy);
|
|
const slice = document.createElement("canvas");
|
|
slice.width = canvas.width;
|
|
slice.height = Math.ceil(sliceH * scale);
|
|
const sctx = slice.getContext("2d");
|
|
if (!sctx) break;
|
|
sctx.drawImage(
|
|
canvas,
|
|
0,
|
|
Math.floor(sy * scale),
|
|
canvas.width,
|
|
slice.height,
|
|
0,
|
|
0,
|
|
slice.width,
|
|
slice.height
|
|
);
|
|
const pageLinks =
|
|
pageIndex === 0 && logoHit.y + logoHit.h <= sliceH ? [logoLinkJpeg] : [];
|
|
pages.push({
|
|
bytes: await canvasToJpegBytes(slice, 0.92),
|
|
w: slice.width,
|
|
h: slice.height,
|
|
links: pageLinks,
|
|
});
|
|
sy += sliceH;
|
|
pageIndex += 1;
|
|
}
|
|
}
|
|
|
|
const blob = jpegsToPdfBlob(pages);
|
|
return new File([blob], `kikomiko-invoice-${data.orderId}.pdf`, {
|
|
type: "application/pdf",
|
|
lastModified: Date.now(),
|
|
});
|
|
}
|
|
|
|
export function downloadFile(file) {
|
|
const url = URL.createObjectURL(file);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = file.name;
|
|
a.rel = "noopener";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
}
|
|
|
|
export function whatsappChatUrl(waNumber, text) {
|
|
const digits = String(waNumber || "").replace(/\D/g, "");
|
|
return `https://wa.me/${digits}?text=${encodeURIComponent(text || "")}`;
|
|
}
|
|
|
|
/** Upload PDF to Worker; returns public URL for WhatsApp (no client download). */
|
|
export async function uploadInvoicePdf(file) {
|
|
const res = await fetch("/api/invoices", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/pdf" },
|
|
body: file,
|
|
});
|
|
const json = await res.json().catch(() => ({}));
|
|
if (!res.ok || !json.ok || !json.url) {
|
|
throw new Error(json.error || "آپلود فاکتور ناموفق بود");
|
|
}
|
|
return String(json.url);
|
|
}
|
|
|
|
/**
|
|
* Host PDF, open WhatsApp to store with invoice link (no client download).
|
|
*/
|
|
export async function openWhatsAppWithInvoice(file, waText, waNumber) {
|
|
const pdfUrl = await uploadInvoicePdf(file);
|
|
const note = [waText || "", "", "📄 Invoice PDF:", pdfUrl]
|
|
.filter((line, i, arr) => !(line === "" && arr[i - 1] === ""))
|
|
.join("\n")
|
|
.trim();
|
|
const url = whatsappChatUrl(waNumber, note);
|
|
window.location.href = url;
|
|
return { pdfUrl, waUrl: url };
|
|
}
|