first deployment of kikomiko with url photos

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shayan khoshrou
2026-10-19 19:10:57 +02:00
co-authored by Cursor
parent 8d98304137
commit ae3ed7c7b7
159 changed files with 59509 additions and 0 deletions
+580
View File
@@ -0,0 +1,580 @@
/**
* 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 };
}