"use client"; import React, { useEffect, useMemo, useState } from "react"; const PRODUCTS = [ { sku: "SKU001", description: "VIVOTEK 4MP Dome Camera", price: 120 }, { sku: "SKU002", description: "VIVOTEK 8MP Bullet Camera", price: 180 }, { sku: "SKU003", description: "VIVOTEK NVR 16CH", price: 420 }, { sku: "SKU004", description: "VIVOTEK PoE Switch 8 Port", price: 95 }, { sku: "SKU005", description: "VIVOTEK PoE Switch 24 Port", price: 210 }, { sku: "SKU006", description: "VIVOTEK Wall Mount Bracket", price: 35 }, { sku: "SKU007", description: "VIVOTEK Ceiling Mount", price: 25 }, { sku: "SKU008", description: "VIVOTEK 2TB Surveillance HDD", price: 80 }, { sku: "SKU009", description: "VIVOTEK 6TB Surveillance HDD", price: 210 }, { sku: "SKU010", description: "VIVOTEK 32CH Enterprise NVR", price: 960 }, ] as const; type Product = (typeof PRODUCTS)[number]; type QuoteLine = Product & { qty: number; margin: number }; type Maybe = T | null | undefined; const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n)); export const currency = (n: number) => new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(n); export const computeSellPrice = (cost: number, marginPct: number) => { const m = clamp(Number.isFinite(marginPct) ? marginPct : 0, -100, 1000); const c = Math.max(0, Number.isFinite(cost) ? cost : 0); return c * (1 + m / 100); }; export const computeLineTotal = (cost: number, marginPct: number, qty: number) => { const q = Math.max(0, Math.floor(Number.isFinite(qty) ? qty : 0)); return computeSellPrice(cost, marginPct) * q; }; const LS_KEY_QUOTE = "customerQuote_v1"; const LS_KEY_LOGO = "customerLogo_v1"; const safeReadLS = (key: string): Maybe => { try { if (typeof window === "undefined" || !window.localStorage) return null; return window.localStorage.getItem(key); } catch { return null; } }; const safeWriteLS = (key: string, value: string) => { try { if (typeof window === "undefined" || !window.localStorage) return; window.localStorage.setItem(key, value); } catch { } }; const safeRemoveLS = (key: string) => { try { if (typeof window === "undefined" || !window.localStorage) return; window.localStorage.removeItem(key); } catch { } }; export default function QuoteBuilderWidget() { const [quote, setQuote] = useState([]); const [logoDataUrl, setLogoDataUrl] = useState>(null); const [error, setError] = useState>(null); useEffect(() => { const saved = safeReadLS(LS_KEY_QUOTE); const logo = safeReadLS(LS_KEY_LOGO); if (saved) { try { const parsed: QuoteLine[] = JSON.parse(saved); if (Array.isArray(parsed)) setQuote(parsed); } catch { } } if (logo) setLogoDataUrl(logo); }, []); useEffect(() => { safeWriteLS(LS_KEY_QUOTE, JSON.stringify(quote)); if (logoDataUrl) safeWriteLS(LS_KEY_LOGO, logoDataUrl); }, [quote, logoDataUrl]); const totals = useMemo(() => { const subtotal = quote.reduce((sum, line) => sum + computeLineTotal(line.price, line.margin, line.qty), 0); const costTotal = quote.reduce((sum, line) => sum + line.price * line.qty, 0); const grossMarginValue = subtotal - costTotal; const grossMarginPct = subtotal > 0 ? (grossMarginValue / subtotal) * 100 : 0; return { subtotal, costTotal, grossMarginValue, grossMarginPct }; }, [quote]); const addToQuote = (p: Product) => setQuote((q) => [...q, { ...p, qty: 1, margin: 30 }]); const updateLine = (i: number, patch: Partial) => setQuote((q) => q.map((line, idx) => (idx === i ? { ...line, ...patch } : line))); const removeLine = (i: number) => setQuote((q) => q.filter((_, idx) => idx !== i)); const handleLogo = (file: File | null) => { if (!file) return; const reader = new FileReader(); reader.onload = () => setLogoDataUrl(String(reader.result)); reader.readAsDataURL(file); }; const clearAll = () => { setQuote([]); setLogoDataUrl(null); safeRemoveLS(LS_KEY_QUOTE); safeRemoveLS(LS_KEY_LOGO); }; const generatePDF = async () => { setError(null); try { let jsPDFCtor: any; try { const mod = await import("jspdf"); jsPDFCtor = (mod as any).jsPDF || (mod as any).default || (mod as any); } catch { jsPDFCtor = typeof window !== "undefined" && (window as any).jspdf && (window as any).jspdf.jsPDF; } if (!jsPDFCtor) throw new Error("jsPDF not available. Install `jspdf` or include CDN script."); const doc = new jsPDFCtor(); if (logoDataUrl) { const mime = logoDataUrl.substring(5, logoDataUrl.indexOf(";")); // e.g. image/png const type = mime.includes("jpeg") || mime.includes("jpg") ? "JPEG" : "PNG"; doc.addImage(logoDataUrl, type, 10, 10, 40, 20); } doc.setFontSize(14); doc.text("Customer Quote", 10, 40); let y = 52; const lineHeight = 8; quote.forEach((item, i) => { const sell = computeSellPrice(item.price, item.margin); const str = `${i + 1}. ${item.sku} – ${item.description} | Qty: ${item.qty} | Cost: ${currency(item.price)} | Margin: ${item.margin}% | Sell: ${currency(sell)}`; if (y > 280) { doc.addPage(); y = 20; } doc.text(str, 10, y); y += lineHeight; }); if (y > 260) { doc.addPage(); y = 20; } doc.setFontSize(12); doc.text(`Cost subtotal: ${currency(totals.costTotal)}`, 10, y); y += lineHeight; doc.text(`Sell subtotal: ${currency(totals.subtotal)}`, 10, y); y += lineHeight; doc.text(`Gross margin: ${currency(totals.grossMarginValue)} (${totals.grossMarginPct.toFixed(1)}%)`, 10, y); doc.save("quote.pdf"); } catch (e: any) { setError(e?.message || "Failed to generate PDF."); } }; return (
{}

Available Products

    {PRODUCTS.map((p) => (
  • {p.sku} – {p.description} ({currency(p.price)} ex VAT)
  • ))}
{}

Your Quote

{}
)}
{} {quote.length === 0 ? (

No items added yet.

) : (
    {quote.map((line, i) => { const sell = computeSellPrice(line.price, line.margin); return (
  • {line.sku} – {line.description}
    updateLine(i, { qty: Math.max(0, parseInt(e.target.value || "0", 10)) })} className="w-20 px-2 py-1 border rounded-lg" >
    updateLine(i, { margin: Number(e.target.value || 0) })} className="w-24 px-2 py-1 border rounded-lg" >
    Sell: {currency(sell)}
    Line total: {currency(sell * line.qty)}
  • ); })}
)} {}
Cost subtotal: {currency(totals.costTotal)}
Sell subtotal: {currency(totals.subtotal)}
Gross margin: {currency(totals.grossMarginValue)} ({totals.grossMarginPct.toFixed(1)}%)
{error && (
{error}
)}
); } if (typeof window !== "undefined" && !(window as any).__QB_TESTED__) { (window as any).__QB_TESTED__ = true; const approx = (a: number, b: number, eps = 1e-9) => Math.abs(a - b) < eps; try { console.assert(approx(computeSellPrice(100, 20), 120), "Sell price 100 @20% should be 120"); console.assert(approx(computeSellPrice(0, 50), 0), "Zero cost should stay zero"); console.assert(approx(computeSellPrice(100, -10), 90), "Negative margin reduces price"); console.assert(approx(computeLineTotal(100, 20, 3), 360), "Line total 3x at 20% margin should be 360"); console.assert(approx(computeLineTotal(50, 0, 5), 250), "No margin 5x £50 should be £250"); console.assert(approx(Number(currency(12.34).replace(/[^0-9.]/g, "")), 12.34), "Currency formatting returns a number-like string"); console.log("QuoteBuilderWidget: all tests passed ✔"); } catch (e) { console.error("QuoteBuilderWidget: tests failed", e); } }