"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 (
);
}
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);
}
}
{}
{}
)}
{}
{quote.length === 0 ? (
{error && (
Available Products
-
{PRODUCTS.map((p) => (
- {p.sku} – {p.description} ({currency(p.price)} ex VAT) ))}
Your Quote
{}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}
)}