first deployment of kikomiko with url photos
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8d98304137
commit
ae3ed7c7b7
Vendored
+580
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user