import { useEffect, useMemo, useRef, useState } from 'react'; import { useBuilder } from '../store/builderStore'; import { computeBom, formatDiameter, formatInches, formatMoney } from './computeBom'; import { bomToText, downloadBomCsv } from './csv'; import { downloadBomPdf } from './pdf'; import type { BomLine, CutList } from './types'; import { exportToGLTF, exportToOBJ } from '../utils/cadExporter'; import { useAuth } from '../auth/AuthContext'; import { PaywallModal } from '../payments/PaywallModal'; /** * BomPanel — self-contained Bill of Materials drawer. * * Renders a fixed-position overlay (no portals) with a right-hand drawer: * summary header, per-category line-item table, expandable cut lists, and * Export CSV / Copy as text / Print actions. Recomputes via useMemo whenever * the parts in the builder store change. * * Mount it conditionally, e.g.: * {bomOpen && setBomOpen(false)} />} */ export function BomPanel({ onClose }: { onClose: () => void }) { const { user } = useAuth(); const [showPaywall, setShowPaywall] = useState(false); const parts = useBuilder((s) => s.parts); const activeRoomId = useBuilder((s) => s.activeRoomId); const projectName = useBuilder((s) => s.projectName); const scene = useBuilder((s) => s.scene); const requirePremium = (action: () => void) => { if (!user || user.purchased_slots === 0) { setShowPaywall(true); } else { action(); } }; const roomParts = useMemo(() => { return Object.values(parts).filter((p) => p.roomId === activeRoomId); }, [parts, activeRoomId]); const bom = useMemo(() => computeBom(roomParts), [roomParts]); const [copied, setCopied] = useState(false); const copyTimer = useRef | null>(null); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => { window.removeEventListener('keydown', onKey); if (copyTimer.current) clearTimeout(copyTimer.current); }; }, [onClose]); const handleCopy = async () => { try { await navigator.clipboard.writeText(bomToText(bom)); setCopied(true); if (copyTimer.current) clearTimeout(copyTimer.current); copyTimer.current = setTimeout(() => setCopied(false), 1500); } catch { /* clipboard unavailable (permissions) — ignore */ } }; const handlePrint = () => { const win = window.open('', '_blank', 'width=720,height=900'); if (!win) return; const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); win.document.write( `${esc(projectName)} — Bill of Materials` + `
` +
        esc(bomToText(bom)) +
        `
`, ); win.document.close(); win.focus(); win.print(); }; // Group lines by category for sectioned rendering. const sections = useMemo(() => { const map = new Map(); for (const line of bom.lines) { const arr = map.get(line.category); if (arr) arr.push(line); else map.set(line.category, [line]); } return [...map.entries()]; }, [bom]); const subtotalOf = (name: string) => bom.categories.find((c) => c.name === name)?.subtotal ?? 0; return (
{/* Backdrop */}
{/* Drawer */}
{/* Header */}

Bill of Materials

{projectName}

{formatMoney(bom.totalCost)} est. total
{bom.partCount} parts · {bom.lines.length} line items {bom.cutLists.length > 0 && ` · ${bom.cutLists.reduce((s, c) => s + c.sticksToBuy, 0)} PVC sticks`}
requirePremium(() => downloadBomCsv(bom, projectName))} title="Download as CSV"> Export CSV 🔒 requirePremium(() => downloadBomPdf(bom, projectName))} title="Download as PDF"> Export PDF 🔒 {copied ? 'Copied' : 'Copy as text'} Print scene && requirePremium(() => exportToGLTF(scene, `${projectName}.gltf`))} title={scene ? "Export 3D layout as glTF" : "Scene loading..."} disabled={!scene} > Export glTF 🔒 scene && requirePremium(() => exportToOBJ(scene, `${projectName}.obj`))} title={scene ? "Export 3D layout as OBJ" : "Scene loading..."} disabled={!scene} > Export OBJ 🔒
{/* Body */}
{bom.partCount === 0 ? (

Nothing here yet — place some parts in the scene to build a shopping list.

) : ( <> {sections.map(([category, lines]) => (

{category}

{formatMoney(subtotalOf(category))}
{lines.map((line, i) => (
{line.description} {line.qty > 1 || line.unit === 'stick' ? ( ×{line.qty} ) : null} {line.estimated && ( est. )}
{line.spec && (
{line.spec}
)}
{formatMoney(line.totalCost)}
{line.qty > 1 && (
{formatMoney(line.unitCost)} / {line.unit}
)}
))}
))} {bom.cutLists.length > 0 && (

Cut list — 10 ft sticks

{bom.cutLists.map((list) => ( ))}
)} )}
{/* Footer */}
Prices are rough US retail estimates, not quotes. {formatMoney(bom.totalCost)}
{showPaywall && setShowPaywall(false)} />}
); } /** Expandable per-diameter cut plan card. */ function CutListCard({ list }: { list: CutList }) { const [open, setOpen] = useState(false); return (
{open && (
{list.sticks.map((stick) => (
Stick {stick.index} {stick.cuts.map((c) => formatInches(c)).join(', ')} — {formatInches(stick.leftoverIn)} left
))} {list.spliceCount > 0 && (

{list.spliceCount} coupling{list.spliceCount === 1 ? '' : 's'} needed to splice runs longer than 10 ft.

)}
)}
); } function ActionBtn({ children, onClick, title, disabled, }: { children: React.ReactNode; onClick: () => void; title?: string; disabled?: boolean; }) { return ( ); }