Add Stripe fee tracking on paid invoices + backfill function

- Store stripe_fee on invoices when webhook receives checkout.session.completed
- Display Stripe fee and net received in InvoiceDetail when paid via Stripe
- Add backfill-stripe-fees edge function to populate fee on existing paid invoices
- Migration: add stripe_fee column to invoices table
- Includes all pending portal changes (brand book, sign survey, task/project/company updates, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-04-14 12:16:22 -04:00
parent 906a0041a4
commit d6e49a4c67
39 changed files with 6618 additions and 300 deletions
+807
View File
@@ -0,0 +1,807 @@
import jsPDF from 'jspdf';
// Letter landscape: 792 x 612 pt
const W = 792;
const H = 612;
const MARGIN = 36;
const ACCENT = [245, 165, 35];
const DARK = [18, 18, 18];
const HEADER_H = 64;
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
// Accepts File, data URL string, or https URL string — returns data URL
async function resolvePhoto(source) {
if (!source) return null;
if (source instanceof File) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => resolve(null);
reader.readAsDataURL(source);
});
}
if (typeof source === 'string') {
if (source.startsWith('data:')) return source;
try {
const resp = await fetch(source);
const blob = await resp.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch { return null; }
}
return null;
}
function loadImage(src) {
return new Promise((resolve) => {
if (!src) return resolve(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
function addHeader(doc, logo, logoW, logoH, title, pageNum, totalPages) {
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, HEADER_H, 'F');
doc.setFillColor(...ACCENT);
doc.rect(0, 0, 4, HEADER_H, 'F');
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.3);
doc.line(0, HEADER_H, W, HEADER_H);
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, (HEADER_H - logoH) / 2, logoW, logoH);
} else {
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text('FOURGE BRANDING', MARGIN, HEADER_H / 2 + 3);
}
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setTextColor(80, 80, 80);
doc.text(title.toUpperCase(), W / 2, HEADER_H / 2 + 3, { align: 'center' });
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 3, { align: 'right' });
}
function addFooter(doc, clientName, date) {
doc.setDrawColor(210, 210, 210);
doc.setLineWidth(0.3);
doc.line(MARGIN, H - 22, W - MARGIN, H - 22);
doc.setFontSize(7);
doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(clientName, MARGIN, H - 12);
doc.text('hello@fourgebranding.com · www.fourgebranding.com', W / 2, H - 12, { align: 'center' });
if (date) doc.text(date, W - MARGIN, H - 12, { align: 'right' });
}
const BOLCHOZ_LOGO_H = 36; // 0.5" client logo height
const BOLCHOZ_FOOTER_H = BOLCHOZ_LOGO_H + 8; // space reserved at bottom for footer
function addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg) {
const logoY = H - MARGIN - BOLCHOZ_LOGO_H;
const pgDivY = H - MARGIN - 3.5; // vertical center of 10pt text
let footerLogoW = 0;
if (clientLogoDataUrl && clientLogoImg) {
const aspect = clientLogoImg.naturalWidth / clientLogoImg.naturalHeight;
footerLogoW = BOLCHOZ_LOGO_H * aspect;
doc.addImage(clientLogoDataUrl, MARGIN, logoY, footerLogoW, BOLCHOZ_LOGO_H);
}
const pgLabel = `Page ${String(pageNum).padStart(2, '0')} of ${String(totalPages).padStart(2, '0')}`;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(pgLabel, W - MARGIN, H - MARGIN, { align: 'right' });
const pgLabelW = doc.getTextWidth(pgLabel);
const divStartX = MARGIN + (footerLogoW > 0 ? footerLogoW + 12 : 0);
const divEndX = W - MARGIN - pgLabelW - 10;
if (divEndX > divStartX) {
doc.setDrawColor(210, 210, 210);
doc.setLineWidth(0.5);
doc.line(divStartX, pgDivY, divEndX, pgDivY);
}
}
export async function generateBrandBookEditorPDF(data) {
const { template = 'fourge', clientName, projectName, siteAddress, bookDate, preparedBy, revision,
siteMapSource, inventoryMapSource, signs, sitePhotoSources,
projectLogoSource, creationDate, revisionDate,
customerName, customerAddress, clientLogoSource,
clientContactName, clientContactEmail, clientContactPhone,
approvedDate, approvalNotes } = data;
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt' });
// Load assets
const logo = await loadImage('/fourge-logo.png');
const logoW = 40;
const logoH = logo ? (logoW / (logo.naturalWidth / logo.naturalHeight)) : 10;
// Resolve all photos to data URLs up front
const [siteMapDataUrl, inventoryMapDataUrl, projectLogoDataUrl, clientLogoDataUrl] = await Promise.all([
resolvePhoto(siteMapSource),
resolvePhoto(inventoryMapSource),
resolvePhoto(projectLogoSource),
resolvePhoto(clientLogoSource),
]);
const clientLogoImg = clientLogoDataUrl ? await loadImage(clientLogoDataUrl) : null;
const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource)));
const sitePhotoDataUrls = await Promise.all((sitePhotoSources || []).map(s => resolvePhoto(s)));
const validSitePhotos = sitePhotoDataUrls.filter(Boolean);
const PHOTOS_PER_PAGE = 16;
// Count pages
let totalPages = 1; // cover
if (siteMapDataUrl) totalPages++; // site map page
totalPages++; // sign inventory
totalPages += signs.length; // sign pages
totalPages += Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE); // site photo pages
let pageNum = 0;
const rev = String(revision || '01').padStart(2, '0');
const displayDate = bookDate
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: '';
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
pageNum++;
if (template === 'bolchoz') {
// ── Bolchoz Sign Solutions Cover ─────────────────────────────────────────────
// White background
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, H, 'F');
// ── Project logo box (top left, 5"×5" = 360×360pt, no border) ──────────────
const logoBoxSize = 288; // 4"
const logoBoxX = MARGIN;
const logoBoxY = MARGIN; // flush to top margin
if (projectLogoDataUrl) {
const pImg = await loadImage(projectLogoDataUrl);
if (pImg) {
const ratio = pImg.naturalWidth / pImg.naturalHeight;
let dW, dH;
if (ratio >= 1) { dW = logoBoxSize; dH = dW / ratio; }
else { dH = logoBoxSize; dW = dH * ratio; }
doc.addImage(projectLogoDataUrl, logoBoxX, logoBoxY, dW, dH);
}
} else {
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(210, 210, 210);
doc.text('PROJECT LOGO', logoBoxX + logoBoxSize / 2, logoBoxY + logoBoxSize / 2, { align: 'center' });
}
// ── Fourge logo (left of right column) ───────────────────────────────────────
const dateColX = logoBoxX + logoBoxSize + 20;
if (logo) {
doc.addImage(logo, 'PNG', dateColX, logoBoxY, logoW, logoH);
} else {
doc.setFontSize(8); doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text('FOURGE BRANDING', dateColX, logoBoxY + 8);
}
// ── Dates (top right corner, right-aligned, starting at same Y as logo top) ──
// Helper: right-align text at W-MARGIN, accounting for charSpace
const rtx = (text, cs = 0) => {
const w = doc.getTextWidth(text) + (text.length > 1 ? (text.length - 1) * cs : 0);
return W - MARGIN - w;
};
let ty = logoBoxY;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('CREATION DATE', rtx('CREATION DATE', 1.5), ty);
doc.setCharSpace(0);
ty += 16;
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
if (creationDate) doc.text(formatDate(creationDate), rtx(formatDate(creationDate)), ty);
ty += 28;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('REVISION DATE', rtx('REVISION DATE', 1.5), ty);
doc.setCharSpace(0);
ty += 16;
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
if (revisionDate) doc.text(formatDate(revisionDate), rtx(formatDate(revisionDate)), ty);
const sepY = logoBoxY + logoBoxSize + 10;
const botY = sepY + 14;
const halfW = (W - MARGIN * 2 - 20) / 2;
const rightColX = MARGIN + halfW + 20;
// ── Bottom left: Customer (anchored to bottom-left corner) ──────────────────
const addrText = customerAddress || siteAddress || '';
const addrLineH = 11;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
const addrLines = addrText ? doc.splitTextToSize(addrText, halfW) : [];
// Work bottom-up to anchor to H - MARGIN
const lY_addr = H - MARGIN;
const lY_addrStart = addrLines.length > 0 ? lY_addr - (addrLines.length - 1) * addrLineH : lY_addr;
const lY_addrLabel = (addrLines.length > 0 ? lY_addrStart : lY_addr) - 16;
const lY_name = lY_addrLabel - 28;
const lY_customerLabel = lY_name - 16;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('CUSTOMER', MARGIN, lY_customerLabel);
doc.setCharSpace(0);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(20, 20, 20);
if (customerName || clientName) doc.text(customerName || clientName, MARGIN, lY_name);
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('ADDRESS', MARGIN, lY_addrLabel);
doc.setCharSpace(0);
if (addrLines.length > 0) {
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
doc.text(addrLines, MARGIN, lY_addrStart);
}
// ── Right column: Signature / Approval (under revision date, right-aligned) ──
const sigLineH = 14; // ~1 line break
let rY = ty + sigLineH * 8; // 8 line breaks below revision date
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('SIGNATURE OF APPROVAL', rtx('SIGNATURE OF APPROVAL', 1.5), rY);
doc.setCharSpace(0);
rY += 16 + 12 + 28; // same spacing as if value were present
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('APPROVED DATE', rtx('APPROVED DATE', 1.5), rY);
doc.setCharSpace(0);
rY += 16;
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
if (approvedDate) doc.text(formatDate(approvedDate), rtx(formatDate(approvedDate)), rY);
rY += 28;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('NOTES', rtx('NOTES', 1.5), rY);
doc.setCharSpace(0);
rY += 16;
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(60, 60, 60);
if (approvalNotes) {
const noteLines = doc.splitTextToSize(approvalNotes, halfW);
doc.text(noteLines, W - MARGIN, rY, { align: 'right' });
}
// ── Client logo + contact (right-bottom aligned) ──────────────────────────────
const clientLogoW = 252; // 3.5"
const clientLogoH = 108; // 1.5"
const contactLineH = 14;
const contactLines = [clientContactName, clientContactEmail, clientContactPhone].filter(Boolean);
const contactBlockH = contactLines.length * contactLineH;
// Contact text right-aligned at bottom
const contactStartY = H - MARGIN - contactBlockH + 10;
if (contactLines.length > 0) {
let cy2 = contactStartY;
if (clientContactName) {
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(40, 40, 40);
doc.text(clientContactName, W - MARGIN, cy2, { align: 'right' });
cy2 += contactLineH;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
if (clientContactEmail) { doc.text(clientContactEmail, W - MARGIN, cy2, { align: 'right' }); cy2 += contactLineH; }
if (clientContactPhone) { doc.text(clientContactPhone, W - MARGIN, cy2, { align: 'right' }); }
}
// Client logo box above contact text
const clBoxBottom = H - MARGIN - (contactBlockH > 0 ? contactBlockH + 8 : 0);
const clBoxTop = clBoxBottom - clientLogoH;
const clBoxLeft = W - MARGIN - clientLogoW;
if (clientLogoDataUrl) {
const clImg = await loadImage(clientLogoDataUrl);
if (clImg) {
const ratio = clImg.naturalWidth / clImg.naturalHeight;
const boxRatio = clientLogoW / clientLogoH;
let dW, dH, dx, dy;
if (ratio > boxRatio) {
dW = clientLogoW; dH = dW / ratio;
dx = clBoxLeft; dy = clBoxTop + (clientLogoH - dH) / 2;
} else {
dH = clientLogoH; dW = dH * ratio;
dx = clBoxLeft + (clientLogoW - dW) / 2; dy = clBoxTop;
}
doc.addImage(clientLogoDataUrl, dx, dy, dW, dH);
}
} else {
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(210, 210, 210);
doc.text('CLIENT LOGO', clBoxLeft + clientLogoW / 2, clBoxTop + clientLogoH / 2, { align: 'center' });
}
} else {
// ── Fourge Branding Default Cover ────────────────────────────────────────────
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, H, 'F');
doc.setFillColor(...ACCENT);
doc.rect(0, 0, W, 5, 'F');
doc.rect(0, H - 5, W, 5, 'F');
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, 18, logoW, logoH);
} else {
doc.setFontSize(10); doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK); doc.text('FOURGE BRANDING', MARGIN, 30);
}
const cY = H / 2;
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
doc.setTextColor(...ACCENT); doc.setCharSpace(3);
doc.text('BRAND BOOK', W / 2, cY - 50, { align: 'center' });
doc.setCharSpace(0);
doc.setDrawColor(...ACCENT); doc.setLineWidth(0.5);
doc.line(W / 2 - 40, cY - 42, W / 2 + 40, cY - 42);
doc.setFontSize(34); doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text(customerName || clientName || '-', W / 2, cY - 14, { align: 'center' });
if (projectName) {
doc.setFontSize(14); doc.setFont('helvetica', 'normal');
doc.setTextColor(100, 100, 100);
doc.text(projectName, W / 2, cY + 14, { align: 'center' });
}
const metaParts = [];
if (siteAddress) metaParts.push(siteAddress);
if (displayDate) metaParts.push(displayDate);
if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`);
if (metaParts.length > 0) {
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.setTextColor(150, 150, 150);
doc.text(metaParts.join(' · '), W / 2, cY + 36, { align: 'center' });
}
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
doc.setTextColor(180, 180, 180);
doc.text(`R${rev}`, W - MARGIN, cY + 36, { align: 'right' });
} // end template branch
// ─── SITE MAP PAGE (optional) ─────────────────────────────────────────────────
if (siteMapDataUrl) {
pageNum++;
doc.addPage();
if (template === 'bolchoz') {
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
// "Site Map" header top-left
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.5);
doc.text('SITE MAP', MARGIN, MARGIN);
doc.setCharSpace(0);
const smLabelH = 22; // space below label before map
const smTop = MARGIN + smLabelH;
const smBottom = H - MARGIN - BOLCHOZ_FOOTER_H;
const smW = W - MARGIN * 2;
const smH = smBottom - smTop;
const smImg = await loadImage(siteMapDataUrl);
if (smImg) {
const aspect = smImg.naturalWidth / smImg.naturalHeight;
const boxAspect = smW / smH;
let dw, dh, dx, dy;
if (aspect > boxAspect) {
dw = smW; dh = dw / aspect; dx = MARGIN; dy = smTop + (smH - dh) / 2;
} else {
dh = smH; dw = dh * aspect; dx = MARGIN + (smW - dw) / 2; dy = smTop;
}
doc.addImage(siteMapDataUrl, dx, dy, dw, dh);
}
} else {
addHeader(doc, logo, logoW, logoH, 'Site Map', pageNum, totalPages);
addFooter(doc, clientName, displayDate);
const smTop = HEADER_H + 16;
const smBottom = H - 30;
const smW = W - MARGIN * 2;
const smH = smBottom - smTop;
const smImg = await loadImage(siteMapDataUrl);
if (smImg) {
const aspect = smImg.naturalWidth / smImg.naturalHeight;
const boxAspect = smW / smH;
let dw, dh, dx, dy;
if (aspect > boxAspect) {
dw = smW; dh = dw / aspect; dx = MARGIN; dy = smTop + (smH - dh) / 2;
} else {
dh = smH; dw = dh * aspect; dx = MARGIN + (smW - dw) / 2; dy = smTop;
}
doc.setDrawColor(200, 200, 200); doc.setLineWidth(0.4);
doc.rect(MARGIN, smTop, smW, smH);
doc.addImage(siteMapDataUrl, dx, dy, dw, dh);
}
}
}
// ─── SIGN INVENTORY PAGE ─────────────────────────────────────────────────────
pageNum++;
doc.addPage();
const invContentTop = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
const invContentBottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
const invContentH = invContentBottom - invContentTop;
const tableW = (W - MARGIN * 2) / 3;
const mapW = W - MARGIN * 2 - tableW - 16;
const mapX = MARGIN;
const mapY = invContentTop;
const tableX = MARGIN + mapW + 16;
if (template === 'bolchoz') {
// White background
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, H, 'F');
// Map at "cover" fill
if (inventoryMapDataUrl) {
const invMapImg = await loadImage(inventoryMapDataUrl);
if (invMapImg) {
const imgAspect = invMapImg.naturalWidth / invMapImg.naturalHeight;
const boxAspect = mapW / invContentH;
let drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawH = invContentH; drawW = invContentH * imgAspect;
drawX = mapX - (drawW - mapW) / 2; drawY = mapY;
} else {
drawW = mapW; drawH = mapW / imgAspect;
drawX = mapX; drawY = mapY - (drawH - invContentH) / 2;
}
doc.addImage(inventoryMapDataUrl, drawX, drawY, drawW, drawH);
}
}
// White overlays to crop image to map box
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, W, mapY, 'F');
doc.rect(0, mapY + invContentH, W, H - mapY - invContentH, 'F');
doc.rect(0, mapY, mapX, invContentH, 'F');
doc.rect(mapX + mapW, mapY, W - mapX - mapW, invContentH, 'F');
if (!inventoryMapDataUrl) {
doc.setFontSize(14);
doc.setFont('helvetica', 'normal');
doc.setTextColor(180, 180, 180);
doc.text('No Map Available', mapX + mapW / 2, mapY + invContentH / 2, { align: 'center' });
}
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
} else {
addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages);
addFooter(doc, clientName, displayDate);
if (inventoryMapDataUrl) {
const invMapImg = await loadImage(inventoryMapDataUrl);
if (invMapImg) {
const imgAspect = invMapImg.naturalWidth / invMapImg.naturalHeight;
const boxAspect = mapW / invContentH;
let drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawW = mapW; drawH = mapW / imgAspect;
drawX = mapX; drawY = mapY + (invContentH - drawH) / 2;
} else {
drawH = invContentH; drawW = invContentH * imgAspect;
drawX = mapX + (mapW - drawW) / 2; drawY = mapY;
}
doc.addImage(inventoryMapDataUrl, drawX, drawY, drawW, drawH);
}
} else {
doc.setFontSize(14);
doc.setFont('helvetica', 'normal');
doc.setTextColor(180, 180, 180);
doc.text('No Map Available', mapX + mapW / 2, mapY + invContentH / 2, { align: 'center' });
}
}
drawInventoryTable(doc, signs, tableX, invContentTop, tableW, invContentH, template);
// ─── SIGN PAGES ───────────────────────────────────────────────────────────────
for (let i = 0; i < signs.length; i++) {
const sign = signs[i];
const photoDataUrl = signPhotoDataUrls[i];
pageNum++;
doc.addPage();
const signLabel = `Sign ${sign.signNumber || (i + 1)}${sign.type || 'Sign'}`;
if (template === 'bolchoz') {
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
} else {
addHeader(doc, logo, logoW, logoH, signLabel, pageNum, totalPages);
addFooter(doc, clientName, displayDate);
}
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 16;
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
const availH = bottom - top;
const photoW = (W - MARGIN * 2 - 20) * 0.45;
const specsX = MARGIN + photoW + 20;
const specsW = W - MARGIN - specsX;
if (photoDataUrl) {
const photoImg = await loadImage(photoDataUrl);
if (photoImg) {
const imgAspect = photoImg.naturalWidth / photoImg.naturalHeight;
const boxAspect = photoW / availH;
let dw, dh, dx, dy;
if (imgAspect > boxAspect) {
dw = photoW; dh = photoW / imgAspect;
dx = MARGIN; dy = top + (availH - dh) / 2;
} else {
dh = availH; dw = availH * imgAspect;
dx = MARGIN + (photoW - dw) / 2; dy = top;
}
doc.setFillColor(245, 245, 245);
doc.rect(MARGIN, top, photoW, availH, 'F');
doc.addImage(photoDataUrl, dx, dy, dw, dh);
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.rect(MARGIN, top, photoW, availH);
}
} else {
doc.setFillColor(245, 245, 245);
doc.rect(MARGIN, top, photoW, availH, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(180, 180, 180);
doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' });
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.rect(MARGIN, top, photoW, availH);
}
let sy = top;
doc.setFillColor(...ACCENT);
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
sy += 26;
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.setTextColor(30, 30, 30);
doc.text(sign.type || 'Sign Type', specsX, sy);
sy += 20;
doc.setDrawColor(...ACCENT);
doc.setLineWidth(1);
doc.line(specsX, sy, specsX + specsW, sy);
sy += 12;
const specs = [
['Location', sign.location || '-'],
['Dimensions', sign.width && sign.height ? `${sign.width}" W × ${sign.height}" H` : '-'],
['Material', sign.material || '— (placeholder)'],
['Illumination', sign.illumination || '— (placeholder)'],
['Condition', sign.condition || '— (placeholder)'],
['Mount Type', sign.mountType || '— (placeholder)'],
];
specs.forEach(([label, value]) => {
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150);
doc.text(label.toUpperCase(), specsX, sy);
sy += 9;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
const wrapped = doc.splitTextToSize(value, specsW);
doc.text(wrapped, specsX, sy);
sy += wrapped.length * 6 + 10;
});
if (sign.notes) {
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150);
doc.text('NOTES', specsX, sy);
sy += 9;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
const noteLines = doc.splitTextToSize(sign.notes, specsW);
doc.text(noteLines, specsX, sy);
}
}
// ─── SITE PHOTOS PAGES (4×4 grid, 16 per page) ───────────────────────────────
if (validSitePhotos.length > 0) {
const cols = 4;
const rows = 4;
const gapX = 8;
const gapY = 8;
const photoPageCount = Math.ceil(validSitePhotos.length / PHOTOS_PER_PAGE);
for (let pg = 0; pg < photoPageCount; pg++) {
pageNum++;
doc.addPage();
const pageLabel = photoPageCount > 1 ? `Site Photos (${pg + 1}/${photoPageCount})` : 'Site Photos';
if (template === 'bolchoz') {
addBolchozFooter(doc, pageNum, totalPages, clientLogoDataUrl, clientLogoImg);
} else {
addHeader(doc, logo, logoW, logoH, pageLabel, pageNum, totalPages);
addFooter(doc, clientName, displayDate);
}
const top = template === 'bolchoz' ? MARGIN : HEADER_H + 14;
const bottom = template === 'bolchoz' ? H - MARGIN - BOLCHOZ_FOOTER_H : H - 30;
const availW = W - MARGIN * 2;
const thumbW = (availW - gapX * (cols - 1)) / cols;
const thumbH = (bottom - top - gapY * (rows - 1)) / rows;
const pagePhotos = validSitePhotos.slice(pg * PHOTOS_PER_PAGE, (pg + 1) * PHOTOS_PER_PAGE);
for (let i = 0; i < pagePhotos.length; i++) {
const dataUrl = pagePhotos[i];
const col = i % cols;
const row = Math.floor(i / cols);
const tx = MARGIN + col * (thumbW + gapX);
const ty = top + row * (thumbH + gapY);
doc.setFillColor(245, 245, 245);
doc.rect(tx, ty, thumbW, thumbH, 'F');
const img = await loadImage(dataUrl);
if (img) {
const aspect = img.naturalWidth / img.naturalHeight;
const boxAspect = thumbW / thumbH;
let dw, dh, dx, dy;
if (aspect > boxAspect) {
dw = thumbW; dh = thumbW / aspect; dx = tx; dy = ty + (thumbH - dh) / 2;
} else {
dh = thumbH; dw = thumbH * aspect; dx = tx + (thumbW - dw) / 2; dy = ty;
}
doc.addImage(dataUrl, dx, dy, dw, dh);
}
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.3);
doc.rect(tx, ty, thumbW, thumbH);
}
}
}
const safePart = (str) => (str || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
const filename = [safePart(projectName), safePart(siteAddress), `R${rev}`].filter(Boolean).join('.');
doc.save(`${filename || 'BrandBook'}.pdf`);
}
function drawInventoryTable(doc, signs, x, y, w, h, template) {
const colDefs = template === 'bolchoz'
? [
{ label: '#', flex: 0.5 },
{ label: 'Existing', flex: 1.5 },
{ label: 'Recommendation', flex: 1.5 },
]
: [
{ label: '#', flex: 0.5 },
{ label: 'Type', flex: 1.5 },
{ label: 'Location', flex: 2 },
{ label: 'Dimensions', flex: 1.2 },
{ label: 'Notes', flex: 2 },
];
const totalFlex = colDefs.reduce((s, c) => s + c.flex, 0);
const cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
const rowH = 18;
doc.setFillColor(...DARK);
doc.rect(x, y, w, rowH, 'F');
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...ACCENT);
let cx = x;
cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; });
let ry = y + rowH;
signs.forEach((sign, i) => {
if (ry > y + h - rowH) return;
const rowData = template === 'bolchoz'
? [
sign.signNumber || String(i + 1),
sign.type || '',
sign.recommendation || '',
]
: [
sign.signNumber || String(i + 1),
sign.type || '-',
sign.location || '-',
sign.width && sign.height ? `${sign.width}" × ${sign.height}"` : '-',
sign.notes || '',
];
doc.setFillColor(i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242);
doc.rect(x, ry, w, rowH, 'F');
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
cx = x;
cols.forEach((col, ci) => {
const truncated = doc.splitTextToSize(rowData[ci] || '', col.w - 8)[0] || '';
doc.text(truncated, cx + 5, ry + 12);
cx += col.w;
});
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.2);
doc.line(x, ry + rowH, x + w, ry + rowH);
ry += rowH;
});
doc.setDrawColor(180, 180, 180);
doc.setLineWidth(0.5);
doc.rect(x, y, w, ry - y);
if (signs.length === 0) {
doc.setFontSize(9);
doc.setFont('helvetica', 'italic');
doc.setTextColor(160, 160, 160);
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
}
}
+546
View File
@@ -0,0 +1,546 @@
import jsPDF from 'jspdf';
function loadImage(src) {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
function hexToRgb(hex) {
const clean = hex.replace('#', '');
const bigint = parseInt(clean, 16);
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
}
function isLight(hex) {
const [r, g, b] = hexToRgb(hex);
return (r * 299 + g * 587 + b * 114) / 1000 > 160;
}
function getImgFormat(dataUrl) {
if (!dataUrl) return 'PNG';
if (/image\/jpe?g/i.test(dataUrl)) return 'JPEG';
return 'PNG';
}
async function toDataUrl(url) {
const img = await loadImage(url);
if (!img) return null;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
return canvas.toDataURL('image/png');
}
function sectionHeader(doc, label, y, pageWidth) {
doc.setFillColor(245, 165, 35);
doc.rect(14, y, 3, 10, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(30, 30, 30);
doc.text(label.toUpperCase(), 21, y + 7);
return y + 18;
}
function addHeader(doc, pageWidth, logo, logoW, logoH, headerH) {
doc.setFillColor(20, 20, 20);
doc.rect(0, 0, pageWidth, headerH, 'F');
if (logo) {
doc.addImage(logo, 'PNG', 14, 6, logoW, logoH);
} else {
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('FOURGE BRANDING', 14, headerH / 2 + 3);
}
doc.setFontSize(7);
doc.setFont('helvetica', 'normal');
doc.setTextColor(130, 130, 130);
doc.text('hello@fourgebranding.com · www.fourgebranding.com', pageWidth - 14, headerH / 2 + 2, { align: 'right' });
}
function addPageNumber(doc, pageNum, total, pageHeight, pageWidth) {
doc.setFontSize(7);
doc.setFont('helvetica', 'normal');
doc.setTextColor(180, 180, 180);
doc.text(`${pageNum} / ${total}`, pageWidth - 14, pageHeight - 8, { align: 'right' });
doc.setDrawColor(230, 230, 230);
doc.setLineWidth(0.3);
doc.line(14, pageHeight - 13, pageWidth - 14, pageHeight - 13);
}
function formatDate(dateStr) {
if (!dateStr) return '—';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
export async function generateBrandBookPDF(data) {
const doc = new jsPDF();
const pageWidth = doc.internal.pageSize.width;
const pageHeight = doc.internal.pageSize.height;
// Preload Fourge logo for inner pages
const fourgeLogoImg = await loadImage('/fourge-logo.png');
const logoW = 36;
const logoH = fourgeLogoImg ? (logoW / (fourgeLogoImg.naturalWidth / fourgeLogoImg.naturalHeight)) : 8;
const headerH = logoH + 12;
// Pre-convert client logo URL to data URL
let clientLogoDataUrl = null;
if (data.clientLogoUrl) {
clientLogoDataUrl = await toDataUrl(data.clientLogoUrl);
}
const colors = (data.colors || []).filter(c => c.name || c.hex);
const hasFonts = data.primaryFont || data.secondaryFont || data.fontNotes;
const hasVoice = data.brandVoice || data.brandAdjectives;
const hasLogo = data.logoNotes;
const hasDoDont = data.dos || data.donts;
let totalPages = 1;
if (data.brandStory || data.brandValues) totalPages++;
if (colors.length > 0) totalPages++;
if (hasFonts) totalPages++;
if (hasVoice) totalPages++;
if (hasLogo || hasDoDont) totalPages++;
let currentPage = 0;
// ─── PAGE 1: Cover ───────────────────────────────────────────────────────────
currentPage++;
const M = 12.7; // 0.5" margin in mm
const logoBox = 127; // 5" × 5" in mm
const clientLogoW = 88.9; // 3.5" in mm
const clientLogoH = 38.1; // 1.5" in mm
// White background
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, pageWidth, pageHeight, 'F');
// ── Project logo box (top left) ──────────────────────────────────────────────
doc.setDrawColor(210, 210, 210);
doc.setLineWidth(0.4);
doc.rect(M, M, logoBox, logoBox);
if (data.projectLogoDataUrl) {
const pImg = await loadImage(data.projectLogoDataUrl);
if (pImg) {
const ratio = pImg.naturalWidth / pImg.naturalHeight;
let dW, dH;
if (ratio >= 1) {
// Landscape/square: fill width first
dW = logoBox;
dH = dW / ratio;
} else {
// Portrait: fill height first
dH = logoBox;
dW = dH * ratio;
}
doc.addImage(data.projectLogoDataUrl, getImgFormat(data.projectLogoDataUrl), M, M, dW, dH);
}
} else {
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(210, 210, 210);
doc.text('PROJECT LOGO', M + logoBox / 2, M + logoBox / 2, { align: 'center' });
}
// ── Dates (top right) ────────────────────────────────────────────────────────
const dateColX = M + logoBox + 10;
let ty = M + 4;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.2);
doc.text('CREATION DATE', dateColX, ty);
doc.setCharSpace(0);
ty += 6;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
doc.text(formatDate(data.creationDate), dateColX, ty);
ty += 16;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.2);
doc.text('REVISION DATE', dateColX, ty);
doc.setCharSpace(0);
ty += 6;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
doc.text(formatDate(data.revisionDate), dateColX, ty);
// ── Separator line ────────────────────────────────────────────────────────────
const sepY = M + logoBox + 7;
doc.setDrawColor(210, 210, 210);
doc.setLineWidth(0.3);
doc.line(M, sepY, pageWidth - M, sepY);
const botY = sepY + 10;
const colW = (pageWidth - 2 * M - 10) / 2;
const rightColX = M + colW + 10;
// ── Bottom left: Customer ─────────────────────────────────────────────────────
let lY = botY;
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.2);
doc.text('CUSTOMER', M, lY);
doc.setCharSpace(0);
lY += 7;
doc.setFontSize(13);
doc.setFont('helvetica', 'bold');
doc.setTextColor(20, 20, 20);
doc.text(data.customerName || '—', M, lY);
lY += 8;
if (data.streetAddress) {
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
const addrLines = doc.splitTextToSize(data.streetAddress, colW);
doc.text(addrLines, M, lY);
}
// ── Bottom right: Signature / Approval ───────────────────────────────────────
let rY = botY;
// Signature of approval
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.2);
doc.text('SIGNATURE OF APPROVAL', rightColX, rY);
doc.setCharSpace(0);
rY += 12;
doc.setDrawColor(180, 180, 180);
doc.setLineWidth(0.3);
doc.line(rightColX, rY, pageWidth - M, rY);
rY += 14;
// Approved date
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.2);
doc.text('APPROVED DATE', rightColX, rY);
doc.setCharSpace(0);
rY += 6;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(30, 30, 30);
doc.text(formatDate(data.approvedDate), rightColX, rY);
rY += 14;
// Notes
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(160, 160, 160);
doc.setCharSpace(1.2);
doc.text('NOTES', rightColX, rY);
doc.setCharSpace(0);
rY += 6;
if (data.approvalNotes) {
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(60, 60, 60);
const noteLines = doc.splitTextToSize(data.approvalNotes, colW);
doc.text(noteLines, rightColX, rY);
}
// ── Client logo + contact (right-bottom aligned) ──────────────────────────────
// Contact text (right-aligned), bottom of page
const contactLineH = 5.5;
const hasContact = data.clientContactName || data.clientContactEmail || data.clientContactPhone;
const contactLines = [data.clientContactName, data.clientContactEmail, data.clientContactPhone].filter(Boolean);
const contactBlockH = contactLines.length * contactLineH;
const contactStartY = pageHeight - M - contactBlockH;
if (contactLines.length > 0) {
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(60, 60, 60);
let cy = contactStartY + 4;
if (data.clientContactName) {
doc.setFont('helvetica', 'bold');
doc.text(data.clientContactName, pageWidth - M, cy, { align: 'right' });
cy += contactLineH;
}
doc.setFont('helvetica', 'normal');
if (data.clientContactEmail) { doc.text(data.clientContactEmail, pageWidth - M, cy, { align: 'right' }); cy += contactLineH; }
if (data.clientContactPhone) { doc.text(data.clientContactPhone, pageWidth - M, cy, { align: 'right' }); }
}
// Client logo box: 3.5" × 1.5", right-bottom, above contact text
const logoBoxGap = hasContact ? contactBlockH + 5 : 4;
const clientLogoBoxBottom = pageHeight - M - (hasContact ? contactBlockH + 6 : 2);
const clientLogoBoxTop = clientLogoBoxBottom - clientLogoH;
const clientLogoBoxLeft = pageWidth - M - clientLogoW;
doc.setDrawColor(210, 210, 210);
doc.setLineWidth(0.3);
doc.rect(clientLogoBoxLeft, clientLogoBoxTop, clientLogoW, clientLogoH);
if (clientLogoDataUrl) {
const clImg = await loadImage(clientLogoDataUrl);
if (clImg) {
const ratio = clImg.naturalWidth / clImg.naturalHeight;
// Scale to contain within box
let dW = clientLogoW, dH = clientLogoH;
if (ratio > clientLogoW / clientLogoH) {
dW = clientLogoW;
dH = dW / ratio;
} else {
dH = clientLogoH;
dW = dH * ratio;
}
// Center in box
const ox = clientLogoBoxLeft + (clientLogoW - dW) / 2;
const oy = clientLogoBoxTop + (clientLogoH - dH) / 2;
doc.addImage(clientLogoDataUrl, 'PNG', ox, oy, dW, dH);
}
} else if (!hasContact) {
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(210, 210, 210);
doc.text('CLIENT LOGO', clientLogoBoxLeft + clientLogoW / 2, clientLogoBoxTop + clientLogoH / 2, { align: 'center' });
}
// ─── PAGE 2: Brand Story + Values ────────────────────────────────────────────
if (data.brandStory || data.brandValues) {
currentPage++;
doc.addPage();
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
let y = headerH + 16;
if (data.brandStory) {
y = sectionHeader(doc, 'Brand Story', y, pageWidth);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(60, 60, 60);
const lines = doc.splitTextToSize(data.brandStory, pageWidth - 28);
doc.text(lines, 14, y);
y += lines.length * 6 + 16;
}
if (data.brandValues) {
y = sectionHeader(doc, 'Brand Values', y, pageWidth);
const values = data.brandValues.split('\n').map(v => v.trim()).filter(Boolean);
values.forEach(val => {
doc.setFillColor(245, 165, 35);
doc.circle(17, y - 1, 1.2, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(50, 50, 50);
doc.text(val, 22, y);
y += 8;
});
}
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
}
// ─── PAGE 3: Color Palette ────────────────────────────────────────────────────
if (colors.length > 0) {
currentPage++;
doc.addPage();
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
let y = headerH + 16;
y = sectionHeader(doc, 'Color Palette', y, pageWidth);
const swatchW = 52;
const swatchH = 40;
const cols = 3;
const gapX = (pageWidth - 28 - swatchW * cols) / (cols - 1);
colors.forEach((color, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = 14 + col * (swatchW + gapX);
const sy = y + row * (swatchH + 22);
const rgb = hexToRgb(color.hex || '#cccccc');
doc.setFillColor(...rgb);
doc.roundedRect(x, sy, swatchW, swatchH, 3, 3, 'F');
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...(isLight(color.hex || '#cccccc') ? [60, 60, 60] : [220, 220, 220]));
doc.text((color.hex || '').toUpperCase(), x + swatchW / 2, sy + swatchH - 5, { align: 'center' });
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setTextColor(40, 40, 40);
doc.text(color.name || 'Unnamed', x + swatchW / 2, sy + swatchH + 8, { align: 'center' });
});
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
}
// ─── PAGE 4: Typography ───────────────────────────────────────────────────────
if (hasFonts) {
currentPage++;
doc.addPage();
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
let y = headerH + 16;
y = sectionHeader(doc, 'Typography', y, pageWidth);
const fontItems = [
data.primaryFont && ['Primary Font', data.primaryFont],
data.secondaryFont && ['Secondary Font', data.secondaryFont],
].filter(Boolean);
fontItems.forEach(([label, fontName]) => {
doc.setFillColor(248, 248, 248);
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.3);
doc.roundedRect(14, y, pageWidth - 28, 28, 3, 3, 'FD');
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150);
doc.text(label.toUpperCase(), 20, y + 8);
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.setTextColor(30, 30, 30);
doc.text(fontName, 20, y + 21);
y += 36;
});
if (data.fontNotes) {
y += 4;
y = sectionHeader(doc, 'Usage Notes', y, pageWidth);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(60, 60, 60);
const lines = doc.splitTextToSize(data.fontNotes, pageWidth - 28);
doc.text(lines, 14, y);
}
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
}
// ─── PAGE 5: Brand Voice ──────────────────────────────────────────────────────
if (hasVoice) {
currentPage++;
doc.addPage();
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
let y = headerH + 16;
y = sectionHeader(doc, 'Brand Voice & Tone', y, pageWidth);
if (data.brandVoice) {
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(60, 60, 60);
const lines = doc.splitTextToSize(data.brandVoice, pageWidth - 28);
doc.text(lines, 14, y);
y += lines.length * 6 + 16;
}
if (data.brandAdjectives) {
y = sectionHeader(doc, 'Brand Personality', y, pageWidth);
const tags = data.brandAdjectives.split(',').map(t => t.trim()).filter(Boolean);
let tx = 14;
tags.forEach(tag => {
const tw = doc.getTextWidth(tag) + 10;
if (tx + tw > pageWidth - 14) { tx = 14; y += 14; }
doc.setFillColor(255, 243, 215);
doc.setDrawColor(245, 165, 35);
doc.setLineWidth(0.3);
doc.roundedRect(tx, y - 6, tw, 10, 2, 2, 'FD');
doc.setFontSize(8);
doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 100, 20);
doc.text(tag, tx + 5, y + 1);
tx += tw + 5;
});
}
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
}
// ─── PAGE 6: Logo + Do's & Don'ts ────────────────────────────────────────────
if (hasLogo || hasDoDont) {
currentPage++;
doc.addPage();
addHeader(doc, pageWidth, fourgeLogoImg, logoW, logoH, headerH);
let y = headerH + 16;
if (hasLogo) {
y = sectionHeader(doc, 'Logo Usage Guidelines', y, pageWidth);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(60, 60, 60);
const lines = doc.splitTextToSize(data.logoNotes, pageWidth - 28);
doc.text(lines, 14, y);
y += lines.length * 6 + 16;
}
if (hasDoDont) {
const colW = (pageWidth - 28 - 8) / 2;
if (data.dos) {
doc.setFillColor(22, 163, 74);
doc.rect(14, y, 3, 10, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(22, 163, 74);
doc.text("DO'S", 21, y + 7);
const dosLines = data.dos.split('\n').map(l => l.trim()).filter(Boolean);
let dy = y + 18;
dosLines.forEach(line => {
doc.setFillColor(22, 163, 74);
doc.circle(16, dy - 1, 1.2, 'F');
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(50, 50, 50);
const wrapped = doc.splitTextToSize(line, colW - 10);
doc.text(wrapped, 21, dy);
dy += wrapped.length * 5.5 + 3;
});
}
if (data.donts) {
const startX = 14 + colW + 8;
doc.setFillColor(220, 38, 38);
doc.rect(startX, y, 3, 10, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(220, 38, 38);
doc.text("DON'TS", startX + 7, y + 7);
const dontsLines = data.donts.split('\n').map(l => l.trim()).filter(Boolean);
let dy = y + 18;
dontsLines.forEach(line => {
doc.setFillColor(220, 38, 38);
doc.circle(startX + 2, dy - 1, 1.2, 'F');
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(50, 50, 50);
const wrapped = doc.splitTextToSize(line, colW - 10);
doc.text(wrapped, startX + 7, dy);
dy += wrapped.length * 5.5 + 3;
});
}
}
addPageNumber(doc, currentPage, totalPages, pageHeight, pageWidth);
}
const safeName = (data.brandName || 'brand-book').toLowerCase().replace(/[^a-z0-9]+/g, '-');
doc.save(`${safeName}-brand-book.pdf`);
}
+47
View File
@@ -0,0 +1,47 @@
import { supabase } from './supabase';
/**
* Deletes all storage files (submissions + deliveries buckets) for the given task IDs.
* Call this before deleting tasks/projects/companies from the DB.
* The DB cascade handles record cleanup — this handles the storage files only.
*/
export async function cleanupTaskStorage(taskIds) {
if (!taskIds?.length) return;
// Get all submissions for these tasks
const { data: subs } = await supabase
.from('submissions')
.select('id')
.in('task_id', taskIds);
const subIds = (subs || []).map(s => s.id);
if (subIds.length) {
// Delete submission files from storage
const { data: subFiles } = await supabase
.from('submission_files')
.select('storage_path')
.in('submission_id', subIds);
const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean);
if (subPaths.length) await supabase.storage.from('submissions').remove(subPaths);
// Get deliveries (linked via submission_id, not task_id)
const { data: deliveries } = await supabase
.from('deliveries')
.select('id')
.in('submission_id', subIds);
const delIds = (deliveries || []).map(d => d.id);
if (delIds.length) {
const { data: delFiles } = await supabase
.from('delivery_files')
.select('storage_path')
.in('delivery_id', delIds);
const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean);
if (delPaths.length) await supabase.storage.from('deliveries').remove(delPaths);
}
}
}
+10 -2
View File
@@ -1,8 +1,16 @@
import { supabase } from './supabase';
export async function sendEmail(type, to, data) {
const { error } = await supabase.functions.invoke('send-email', {
const { data: result, error } = await supabase.functions.invoke('send-email', {
body: { type, to, data },
});
if (error) console.error('Email error:', error);
if (error) {
console.error('Email invoke error:', error);
throw new Error(`Email failed: ${error.message || JSON.stringify(error)}`);
}
if (result?.error) {
console.error('Email send error:', result.error);
throw new Error(`Email failed: ${JSON.stringify(result.error)}`);
}
return result;
}
+441
View File
@@ -0,0 +1,441 @@
import jsPDF from 'jspdf';
// Letter landscape: 792 x 612 pt
const W = 792;
const H = 612;
const MARGIN = 36;
const ACCENT = [245, 165, 35];
const DARK = [18, 18, 18];
const HEADER_H = 32;
// Accepts File, data URL string, or https URL string — returns data URL
async function resolvePhoto(source) {
if (!source) return null;
if (source instanceof File) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => resolve(null);
reader.readAsDataURL(source);
});
}
if (typeof source === 'string') {
if (source.startsWith('data:')) return source;
try {
const resp = await fetch(source);
const blob = await resp.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch { return null; }
}
return null;
}
function loadImage(src) {
return new Promise((resolve) => {
if (!src) return resolve(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
function addHeader(doc, logo, logoW, logoH, title, pageNum, totalPages) {
doc.setFillColor(...DARK);
doc.rect(0, 0, W, HEADER_H, 'F');
doc.setFillColor(...ACCENT);
doc.rect(0, 0, 4, HEADER_H, 'F');
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, (HEADER_H - logoH) / 2, logoW, logoH);
} else {
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('FOURGE BRANDING', MARGIN, HEADER_H / 2 + 3);
}
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setTextColor(200, 200, 200);
doc.text(title.toUpperCase(), W / 2, HEADER_H / 2 + 3, { align: 'center' });
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(120, 120, 120);
doc.text(`${pageNum} / ${totalPages}`, W - MARGIN, HEADER_H / 2 + 3, { align: 'right' });
}
function addFooter(doc, clientName, date) {
doc.setDrawColor(60, 60, 60);
doc.setLineWidth(0.3);
doc.line(MARGIN, H - 22, W - MARGIN, H - 22);
doc.setFontSize(7);
doc.setFont('helvetica', 'normal');
doc.setTextColor(130, 130, 130);
doc.text(clientName, MARGIN, H - 12);
doc.text('hello@fourgebranding.com · www.fourgebranding.com', W / 2, H - 12, { align: 'center' });
if (date) doc.text(date, W - MARGIN, H - 12, { align: 'right' });
}
export async function generateBrandBookEditorPDF(data) {
const { clientName, projectName, siteAddress, bookDate, preparedBy, revision,
siteMapSource, signs, surveyPhotoSources } = data;
const doc = new jsPDF({ orientation: 'landscape', format: 'letter', unit: 'pt' });
// Load assets
const logo = await loadImage('/fourge-logo.png');
const logoW = 40;
const logoH = logo ? (logoW / (logo.naturalWidth / logo.naturalHeight)) : 10;
// Resolve all photos to data URLs up front
const siteMapDataUrl = await resolvePhoto(siteMapSource);
const signPhotoDataUrls = await Promise.all(signs.map(s => resolvePhoto(s.photoSource)));
const surveyPhotoDataUrls = await Promise.all((surveyPhotoSources || []).map(s => resolvePhoto(s)));
// Count pages
let totalPages = 1; // cover
totalPages++; // sign inventory
totalPages += signs.length;
if (surveyPhotoDataUrls.some(Boolean)) totalPages++;
let pageNum = 0;
const displayDate = bookDate
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: '';
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
pageNum++;
doc.setFillColor(...DARK);
doc.rect(0, 0, W, H, 'F');
doc.setFillColor(...ACCENT);
doc.rect(0, 0, W, 4, 'F');
doc.rect(0, H - 4, W, 4, 'F');
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, 22, logoW, logoH);
} else {
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('FOURGE BRANDING', MARGIN, 38);
}
const cy = H / 2;
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...ACCENT);
doc.setCharSpace(3);
doc.text('BRAND BOOK', W / 2, cy - 54, { align: 'center' });
doc.setCharSpace(0);
doc.setDrawColor(...ACCENT);
doc.setLineWidth(0.5);
doc.line(W / 2 - 40, cy - 46, W / 2 + 40, cy - 46);
doc.setFontSize(36);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text(clientName || 'Client Name', W / 2, cy - 18, { align: 'center' });
if (projectName) {
doc.setFontSize(14);
doc.setFont('helvetica', 'normal');
doc.setTextColor(160, 160, 160);
doc.text(projectName, W / 2, cy + 12, { align: 'center' });
}
const metaParts = [];
if (siteAddress) metaParts.push(siteAddress);
if (displayDate) metaParts.push(displayDate);
if (preparedBy) metaParts.push(`Prepared by ${preparedBy}`);
if (metaParts.length > 0) {
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(100, 100, 100);
doc.text(metaParts.join(' · '), W / 2, cy + 36, { align: 'center' });
}
// Revision badge bottom right of cover
const rev = String(revision || '01').padStart(2, '0');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(80, 80, 80);
doc.text(`R${rev}`, W - MARGIN, cy + 36, { align: 'right' });
// ─── PAGE 2: SIGN INVENTORY ───────────────────────────────────────────────────
pageNum++;
doc.addPage();
addHeader(doc, logo, logoW, logoH, 'Sign Inventory', pageNum, totalPages);
addFooter(doc, clientName, displayDate);
const contentTop = HEADER_H + 16;
const contentBottom = H - 30;
const contentH = contentBottom - contentTop;
if (siteMapDataUrl) {
const mapW = (W - MARGIN * 2 - 16) * 0.55;
const mapH = contentH;
const mapX = MARGIN;
const mapY = contentTop;
const siteImg = await loadImage(siteMapDataUrl);
if (siteImg) {
const imgAspect = siteImg.naturalWidth / siteImg.naturalHeight;
const boxAspect = mapW / mapH;
let drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawW = mapW; drawH = mapW / imgAspect;
drawX = mapX; drawY = mapY + (mapH - drawH) / 2;
} else {
drawH = mapH; drawW = mapH * imgAspect;
drawX = mapX + (mapW - drawW) / 2; drawY = mapY;
}
doc.setDrawColor(80, 80, 80);
doc.setLineWidth(0.5);
doc.rect(mapX, mapY, mapW, mapH);
doc.addImage(siteMapDataUrl, drawX, drawY, drawW, drawH);
}
const tableX = MARGIN + mapW + 16;
const tableW = W - MARGIN - tableX;
drawInventoryTable(doc, signs, tableX, contentTop, tableW, contentH);
} else {
drawInventoryTable(doc, signs, MARGIN, contentTop, W - MARGIN * 2, contentH);
}
// ─── SIGN PAGES ───────────────────────────────────────────────────────────────
for (let i = 0; i < signs.length; i++) {
const sign = signs[i];
const photoDataUrl = signPhotoDataUrls[i];
pageNum++;
doc.addPage();
const signLabel = `Sign ${sign.signNumber || (i + 1)}${sign.type || 'Sign'}`;
addHeader(doc, logo, logoW, logoH, signLabel, pageNum, totalPages);
addFooter(doc, clientName, displayDate);
const top = HEADER_H + 16;
const bottom = H - 30;
const availH = bottom - top;
const photoW = (W - MARGIN * 2 - 20) * 0.45;
const specsX = MARGIN + photoW + 20;
const specsW = W - MARGIN - specsX;
if (photoDataUrl) {
const photoImg = await loadImage(photoDataUrl);
if (photoImg) {
const imgAspect = photoImg.naturalWidth / photoImg.naturalHeight;
const boxAspect = photoW / availH;
let dw, dh, dx, dy;
if (imgAspect > boxAspect) {
dw = photoW; dh = photoW / imgAspect;
dx = MARGIN; dy = top + (availH - dh) / 2;
} else {
dh = availH; dw = availH * imgAspect;
dx = MARGIN + (photoW - dw) / 2; dy = top;
}
doc.setFillColor(30, 30, 30);
doc.rect(MARGIN, top, photoW, availH, 'F');
doc.addImage(photoDataUrl, dx, dy, dw, dh);
doc.setDrawColor(60, 60, 60);
doc.setLineWidth(0.5);
doc.rect(MARGIN, top, photoW, availH);
}
} else {
doc.setFillColor(30, 30, 30);
doc.rect(MARGIN, top, photoW, availH, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
doc.text('No Photo', MARGIN + photoW / 2, top + availH / 2, { align: 'center' });
doc.setDrawColor(60, 60, 60);
doc.setLineWidth(0.5);
doc.rect(MARGIN, top, photoW, availH);
}
let sy = top;
doc.setFillColor(...ACCENT);
doc.roundedRect(specsX, sy, 44, 18, 3, 3, 'F');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...DARK);
doc.text(`#${sign.signNumber || (i + 1)}`, specsX + 22, sy + 12, { align: 'center' });
sy += 26;
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.setTextColor(30, 30, 30);
doc.text(sign.type || 'Sign Type', specsX, sy);
sy += 20;
doc.setDrawColor(...ACCENT);
doc.setLineWidth(1);
doc.line(specsX, sy, specsX + specsW, sy);
sy += 12;
const specs = [
['Location', sign.location || '—'],
['Dimensions', sign.width && sign.height ? `${sign.width}" W × ${sign.height}" H` : '—'],
['Material', sign.material || '— (placeholder)'],
['Illumination', sign.illumination || '— (placeholder)'],
['Condition', sign.condition || '— (placeholder)'],
['Mount Type', sign.mountType || '— (placeholder)'],
];
specs.forEach(([label, value]) => {
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150);
doc.text(label.toUpperCase(), specsX, sy);
sy += 9;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
const wrapped = doc.splitTextToSize(value, specsW);
doc.text(wrapped, specsX, sy);
sy += wrapped.length * 6 + 10;
});
if (sign.notes) {
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(150, 150, 150);
doc.text('NOTES', specsX, sy);
sy += 9;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(80, 80, 80);
const noteLines = doc.splitTextToSize(sign.notes, specsW);
doc.text(noteLines, specsX, sy);
}
}
// ─── SURVEY PHOTOS PAGE ───────────────────────────────────────────────────────
const validSurveyPhotos = surveyPhotoDataUrls.filter(Boolean);
if (validSurveyPhotos.length > 0) {
pageNum++;
doc.addPage();
addHeader(doc, logo, logoW, logoH, 'Site Photos', pageNum, totalPages);
addFooter(doc, clientName, displayDate);
const top = HEADER_H + 14;
const bottom = H - 30;
const availW = W - MARGIN * 2;
const cols = 4;
const rows = 3;
const gapX = 10;
const gapY = 10;
const thumbW = (availW - gapX * (cols - 1)) / cols;
const thumbH = (bottom - top - gapY * (rows - 1)) / rows;
for (let i = 0; i < Math.min(validSurveyPhotos.length, cols * rows); i++) {
const dataUrl = validSurveyPhotos[i];
const col = i % cols;
const row = Math.floor(i / cols);
const tx = MARGIN + col * (thumbW + gapX);
const ty = top + row * (thumbH + gapY);
doc.setFillColor(30, 30, 30);
doc.rect(tx, ty, thumbW, thumbH, 'F');
const img = await loadImage(dataUrl);
if (img) {
const aspect = img.naturalWidth / img.naturalHeight;
const boxAspect = thumbW / thumbH;
let dw, dh, dx, dy;
if (aspect > boxAspect) {
dw = thumbW; dh = thumbW / aspect; dx = tx; dy = ty + (thumbH - dh) / 2;
} else {
dh = thumbH; dw = thumbH * aspect; dx = tx + (thumbW - dw) / 2; dy = ty;
}
doc.addImage(dataUrl, dx, dy, dw, dh);
}
doc.setDrawColor(60, 60, 60);
doc.setLineWidth(0.3);
doc.rect(tx, ty, thumbW, thumbH);
}
if (validSurveyPhotos.length > cols * rows) {
doc.setFontSize(8);
doc.setFont('helvetica', 'italic');
doc.setTextColor(120, 120, 120);
doc.text(`+${validSurveyPhotos.length - cols * rows} more photos not shown`, W - MARGIN, bottom + 8, { align: 'right' });
}
}
const safePart = (str) => (str || '').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, ' ');
const filename = [safePart(projectName), safePart(siteAddress), `R${rev}`].filter(Boolean).join('.');
doc.save(`${filename || 'BrandBook'}.pdf`);
}
function drawInventoryTable(doc, signs, x, y, w, h) {
const colDefs = [
{ label: '#', flex: 0.5 },
{ label: 'Type', flex: 1.5 },
{ label: 'Location', flex: 2 },
{ label: 'Dimensions', flex: 1.2 },
{ label: 'Notes', flex: 2 },
];
const totalFlex = colDefs.reduce((s, c) => s + c.flex, 0);
const cols = colDefs.map(c => ({ ...c, w: (c.flex / totalFlex) * w }));
const rowH = 18;
doc.setFillColor(30, 30, 30);
doc.rect(x, y, w, rowH, 'F');
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...ACCENT);
let cx = x;
cols.forEach(col => { doc.text(col.label, cx + 5, y + 12); cx += col.w; });
let ry = y + rowH;
signs.forEach((sign, i) => {
if (ry > y + h - rowH) return;
const rowData = [
sign.signNumber || String(i + 1),
sign.type || '—',
sign.location || '—',
sign.width && sign.height ? `${sign.width}" × ${sign.height}"` : '—',
sign.notes || '',
];
doc.setFillColor(i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242, i % 2 === 0 ? 248 : 242);
doc.rect(x, ry, w, rowH, 'F');
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
cx = x;
cols.forEach((col, ci) => {
const truncated = doc.splitTextToSize(rowData[ci] || '', col.w - 8)[0] || '';
doc.text(truncated, cx + 5, ry + 12);
cx += col.w;
});
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.2);
doc.line(x, ry + rowH, x + w, ry + rowH);
ry += rowH;
});
doc.setDrawColor(180, 180, 180);
doc.setLineWidth(0.5);
doc.rect(x, y, w, ry - y);
if (signs.length === 0) {
doc.setFontSize(9);
doc.setFont('helvetica', 'italic');
doc.setTextColor(160, 160, 160);
doc.text('No signs added yet', x + w / 2, y + rowH + 16, { align: 'center' });
}
}