import { useEffect, useState, useMemo } from 'react'; import { SceneCanvas, clientToGround } from './components/SceneCanvas'; import { PartLibrary } from './components/PartLibrary'; import { PropertiesPanel } from './components/PropertiesPanel'; import { Toolbar } from './components/Toolbar'; import { StatusPanel } from './components/StatusPanel'; import { ColorPopover } from './components/ColorPopover'; import { BomPanel } from './bom'; import { DesignCheckPanel, validateSystem } from './validation'; import { getHotbarPartForKey } from './parts/hotbar'; import { useSimulation } from './simulation/flowSimulator'; import { snapValue, useBuilder } from './store/builderStore'; import { autosave, loadAutosave } from './utils/serializer'; import { demoParts } from './utils/demoProject'; import type { PartType } from './types'; import type { Finding } from './validation'; import { AuthProvider, useAuth } from './auth/AuthContext'; import { useCollab } from './hooks/useCollab'; import { LoginModal } from './auth/LoginModal'; import { ToastContainer, addToast } from './components/Toast'; import { useSaveStatus } from './store/saveStatusStore'; import { TestimonialScroller } from './components/TestimonialScroller'; import { ScheduleDashboard } from './components/ScheduleDashboard'; /** Restore the autosaved design, or seed the demo system on first visit. */ /** Restore the autosaved design, or seed the demo system on first visit. */ function useBootstrap(email?: string, loading?: boolean) { const { user } = useAuth(); const hasUpgraded = user && user.purchased_slots > 0; useEffect(() => { if (loading) return; const saved = hasUpgraded ? loadAutosave(email) : null; if (saved && saved.parts.length > 0) { useBuilder.getState().loadParts(saved.parts, saved.name); } else { useBuilder.getState().loadParts(demoParts(), 'Demo: pump loop'); } }, [email, loading, hasUpgraded]); } /** Debounced autosave whenever parts change. */ function useAutosave(email?: string, loading?: boolean) { const { user } = useAuth(); const hasUpgraded = user && user.purchased_slots > 0; const setStatus = useSaveStatus((s) => s.setStatus); useEffect(() => { if (loading) return; if (!hasUpgraded) { setStatus('unsaved'); return; } let timer: ReturnType | undefined; let hasUnsavedChanges = false; const unsub = useBuilder.subscribe((s, prev) => { if (s.parts !== prev.parts) { hasUnsavedChanges = true; setStatus('unsaved'); } const isDragging = s.draggingId !== null || s.gizmoDragging; const wasDragging = prev.draggingId !== null || prev.gizmoDragging; const dragEnded = wasDragging && !isDragging; const partsChangedWhileNotDragging = (s.parts !== prev.parts) && !isDragging; if (hasUnsavedChanges && (dragEnded || partsChangedWhileNotDragging)) { clearTimeout(timer); setStatus('saving'); timer = setTimeout(() => { autosave(useBuilder.getState().projectName, Object.values(useBuilder.getState().parts), email); hasUnsavedChanges = false; setStatus('saved'); }, 600); } else if (isDragging) { clearTimeout(timer); timer = undefined; } }); return () => { unsub(); clearTimeout(timer); }; }, [email, loading, hasUpgraded, setStatus]); } /** Global keyboard shortcuts. */ function useShortcuts() { useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; const s = useBuilder.getState(); const mod = e.metaKey || e.ctrlKey; if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? s.redo() : s.undo(); return; } if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); s.redo(); return; } if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); s.selectAll(); return; } if (mod && e.key.toLowerCase() === 'c') { const selectedParts = s.selectedIds.map(id => s.parts[id]).filter(Boolean); if (selectedParts.length > 0) { e.preventDefault(); navigator.clipboard.writeText(JSON.stringify({ app: 'hydro-builder-clip', parts: selectedParts })); addToast(`Copied ${selectedParts.length} parts`, 'info'); } return; } if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); navigator.clipboard.readText().then((text) => { try { const data = JSON.parse(text); if (data && data.app === 'hydro-builder-clip' && Array.isArray(data.parts)) { const pastedIds = s.pasteParts(data.parts); addToast(`Pasted ${pastedIds.length} parts`, 'success'); } else { addToast('Clipboard does not contain valid hydro parts', 'error'); } } catch (err) { addToast(err instanceof Error ? `Paste failed: ${err.message}` : 'Clipboard does not contain valid hydro parts', 'error'); } }).catch(() => { addToast('Could not read clipboard', 'error'); }); return; } if (mod) return; // don't hijack other browser shortcuts const hotbarType = getHotbarPartForKey(e.key); if (hotbarType) { e.preventDefault(); if (s.tool !== 'select') s.setTool('select'); s.setPlacingType(s.placingType === hotbarType ? null : hotbarType); return; } switch (e.key) { case ' ': e.preventDefault(); // keep Space from scrolling / re-clicking buttons s.toggleSimRunning(); break; case 'Escape': if (s.colorPopover) { s.closeColorPopover(); } else { s.setTool('select'); s.setSelected(null); } break; case 'Delete': case 'Backspace': s.removeParts(s.selectedIds); break; case 'r': case 'R': s.rotateParts(s.selectedIds, 1, (e.shiftKey ? -1 : 1) * (Math.PI / 2)); break; case 'd': case 'D': e.preventDefault(); s.duplicateParts(s.selectedIds); break; case 'q': case 'Q': s.translateParts(s.selectedIds, [0, -s.gridSize, 0], 'nudge'); break; case 'e': case 'E': s.translateParts(s.selectedIds, [0, s.gridSize, 0], 'nudge'); break; } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); } import { PaywallModal } from './payments/PaywallModal'; function BuilderApp() { const { user, loading: authLoading } = useAuth(); const [showPaywall, setShowPaywall] = useState(false); const email = user?.email; useBootstrap(email, authLoading); useAutosave(email, authLoading); useShortcuts(); const { remoteCursors } = useCollab(); const placingType = useBuilder((s) => s.placingType); const tool = useBuilder((s) => s.tool); const selectedCount = useBuilder((s) => s.selectedIds.length); const viewMode = useBuilder((s) => s.viewMode); const renderMode = useBuilder((s) => s.renderMode); const parts = useBuilder((s) => s.parts); const activeRoomId = useBuilder((s) => s.activeRoomId); const sim = useSimulation(); const [bomOpen, setBomOpen] = useState(false); const [dashboardOpen, setDashboardOpen] = useState(false); const [checkFindings, setCheckFindings] = useState(null); const roomParts = useMemo(() => { return Object.values(parts).filter((p) => p.roomId === activeRoomId); }, [parts, activeRoomId]); const runDesignCheck = () => { setCheckFindings(validateSystem(roomParts, sim)); }; const applyFindingFix = (finding: Finding) => { const s = useBuilder.getState(); if (finding.id.startsWith('dead-leg:')) { s.fixDeadLeg(finding.partIds); } else if (finding.id.startsWith('diameter-mismatch:')) { s.fixDiameterMismatch(finding.partIds); } else if (finding.id.startsWith('pump-dead:')) { const pumpId = finding.partIds[0]; if (pumpId) { const p = s.parts[pumpId]; if (p && (p.params.on === 0 || p.params.enabled === 0)) { s.updatePart(pumpId, { params: { ...p.params, on: 1, enabled: 1 } }); } else { s.fixPumpDeadEnd(pumpId); } } } else if (finding.id.startsWith('pump-head:')) { const pumpId = finding.partIds[0]; if (pumpId) { const p = s.parts[pumpId]; if (p) { const requiredHead = Math.ceil((p.params.maxHeadFt || 8) * 1.5); s.updatePart(pumpId, { params: { ...p.params, maxHeadFt: Math.min(30, requiredHead) } }); } } } else if (finding.id.startsWith('unsupported-span:')) { const pipeId = finding.partIds[0]; if (pipeId) { const p = s.parts[pipeId]; if (p) { const y = p.position[1]; const supportHeight = Math.max(0.5, y - 0.1); const latticeId = s.addPart('lattice', [p.position[0], supportHeight / 2, p.position[2]], { snap: false }); const lattice = useBuilder.getState().parts[latticeId]; if (lattice) { s.updatePart(latticeId, { params: { ...lattice.params, height: supportHeight } }); } } } } else if (finding.id.startsWith('drain-capacity:')) { const drainId = finding.partIds[0]; if (drainId) { const p = s.parts[drainId]; if (p) { const reported = sim.drains?.find((d) => d?.partId === drainId)?.inflowGph; const inflow = reported ?? sim.flows?.[drainId] ?? 0; const needed = Math.ceil(inflow / 25) * 25; s.updatePart(drainId, { params: { ...p.params, capacityGph: Math.min(2000, needed) } }); } } } else if (finding.id.startsWith('res-temp-high:')) { const chillers = Object.values(s.parts).filter((p) => p.type === 'waterChiller'); for (const chiller of chillers) { s.updatePart(chiller.id, { params: { ...chiller.params, on: 1, targetTemp: 68 } }); } } else if (finding.id.startsWith('res-ph-low:') || finding.id.startsWith('res-ph-high:')) { const resId = finding.partIds[0]; if (resId) { const p = s.parts[resId]; if (p) s.updatePart(resId, { params: { ...p.params, ph: 6.0 } }); } } else if (finding.id.startsWith('res-ec-low:') || finding.id.startsWith('res-ec-high:')) { const resId = finding.partIds[0]; if (resId) { const p = s.parts[resId]; if (p) s.updatePart(resId, { params: { ...p.params, ec: 1.2 } }); } } else if (finding.id.startsWith('room-co2-open:') || finding.id.startsWith('room-co2-missing:') || finding.id.startsWith('room-co2-starved:')) { const tentId = finding.partIds[0]; if (tentId) { const p = s.parts[tentId]; if (p) { // If the CO2 open warning is active, seal the room. If CO2 is starved/missing, unseal the room to swap with fresh air. const isCo2Open = finding.id.startsWith('room-co2-open:'); s.updatePart(tentId, { params: { ...p.params, sealed: isCo2Open ? 1 : 0 } }); } } } else if (finding.id.startsWith('room-sealed-exhaust:')) { const tentId = finding.partIds[0]; if (tentId) { const p = s.parts[tentId]; if (p) s.updatePart(tentId, { params: { ...p.params, sealed: 0 } }); } } else if (finding.id.startsWith('room-vent-low:')) { const fans = Object.values(s.parts).filter((p) => p.type === 'inlineFan'); for (const fan of fans) { s.updatePart(fan.id, { params: { ...fan.params, cfm: 600 } }); } } else if (finding.id.startsWith('room-light-low:')) { const lights = Object.values(s.parts).filter((p) => p.type === 'growLight'); for (const light of lights) { s.updatePart(light.id, { params: { ...light.params, watts: Math.min(1000, (light.params.watts ?? 650) + 150) } }); } } else if (finding.id.startsWith('room-light-high:')) { const lights = Object.values(s.parts).filter((p) => p.type === 'growLight'); for (const light of lights) { s.updatePart(light.id, { params: { ...light.params, watts: Math.max(100, (light.params.watts ?? 650) - 200) } }); } } else if (finding.id.startsWith('room-dli-low:')) { const lights = Object.values(s.parts).filter((p) => p.type === 'growLight'); for (const light of lights) { s.updatePart(light.id, { params: { ...light.params, photoperiod: Math.min(24, (light.params.photoperiod ?? 18) + 4) } }); } } else if (finding.id.startsWith('room-dli-high:')) { const lights = Object.values(s.parts).filter((p) => p.type === 'growLight'); for (const light of lights) { s.updatePart(light.id, { params: { ...light.params, photoperiod: Math.max(0, (light.params.photoperiod ?? 18) - 6) } }); } } else if (finding.id.startsWith('room-vpd-low:')) { const lights = Object.values(s.parts).filter((p) => p.type === 'growLight'); for (const light of lights) { s.updatePart(light.id, { params: { ...light.params, watts: Math.min(1000, (light.params.watts ?? 650) + 150) } }); } } else if (finding.id.startsWith('room-vpd-high:')) { const lights = Object.values(s.parts).filter((p) => p.type === 'growLight'); for (const light of lights) { s.updatePart(light.id, { params: { ...light.params, watts: Math.max(100, (light.params.watts ?? 650) - 150) } }); } } else if (finding.id.startsWith('closed-valve:')) { const valveId = finding.partIds[0]; if (valveId) { const p = s.parts[valveId]; if (p) s.updatePart(valveId, { params: { ...p.params, open: 100 } }); } } else if (finding.id.startsWith('part-overlap:')) { const ids = finding.partIds; if (ids.length >= 2) { s.removeParts([ids[1]]); } } else if (finding.id.startsWith('nutrient-depletion:')) { const resId = finding.partIds[0]; if (resId) { const p = s.parts[resId]; if (p) { s.updatePart(resId, { params: { ...p.params, width: Math.min(5, (p.params.width ?? 2) * 1.5), depth: Math.min(4, (p.params.depth ?? 1.4) * 1.5), height: Math.min(3, (p.params.height ?? 1) * 1.2), } }); } } } else if (finding.id.startsWith('orphan:')) { s.removeParts(finding.partIds); } setTimeout(() => { const nextParts = Object.values(useBuilder.getState().parts); setCheckFindings(validateSystem(nextParts, sim)); }, 50); }; /** Handle HTML5 drag-and-drop from the parts library onto the canvas. */ const onDrop = (e: React.DragEvent) => { const type = e.dataTransfer.getData('application/x-hydro-part') as PartType; if (!type) return; e.preventDefault(); const ground = clientToGround(e.clientX, e.clientY); if (!ground) return; const s = useBuilder.getState(); const snap = (v: number) => (s.snapToGrid ? snapValue(v, s.gridSize) : v); const y = ['pipe', 'elbow', 'tee', 'valve', 'emitter'].includes(type) ? 0.25 : 0; s.addPart(type, [snap(ground[0]), y, snap(ground[2])]); }; if (authLoading) { return (
Hydro Builder initializing...
); } if (!user) { return ; } return (
setBomOpen(true)} onCheckBuild={runDesignCheck} onOpenDashboard={() => setDashboardOpen(true)} />
e.preventDefault()} onDrop={onDrop} > {tool === 'measure' && (
📏 Measurement mode: Click two points in the 3D scene
)} {/* Premium Upsell Bar */} {!user || (user as any).purchased_slots === 0 ? (

Unlock Pro Premium

One-time $5 special offer. Unlimited projects, PDF shopping lists, CAD exports, and drjonesbot.

) : null} {/* Contextual hint overlay */} {(placingType || tool === 'measure' || tool === 'pipeRun') && (
{placingType ? 'Click to place · Shift-click to stamp copies · 1-9 switches parts · Esc to cancel' : tool === 'pipeRun' ? 'Pipe Run: click points · Shift-click to keep routing · Esc to exit' : 'Measure: click two points · Esc to exit'}
)} {/* Selection hint */} {!placingType && tool !== 'measure' && selectedCount > 0 && (
{selectedCount > 1 ? `${selectedCount} parts — drag moves all · ⌥drag clones · ⇧click adds/removes · ⌘click color · D duplicate · ⌫ delete` : `Drag body or gizmo to move · double-click plumbing to grab the whole run · selected pipes show stretch handles · drag a connector dot to snap${viewMode === 'orbit' ? ' · ⇧drag for vertical' : ''} · ${renderMode === 'xray' ? 'X-Ray shows internals' : renderMode === 'water' ? 'Water mode isolates flow' : 'Builder mode keeps solids primary'} · ⌥drag clones · ⌘click color · R rotate`}
)}
{bomOpen && setBomOpen(false)} />} {dashboardOpen && setDashboardOpen(false)} />} {checkFindings && ( { const s = useBuilder.getState(); s.setSelected(id); if (id) { const part = s.parts[id]; if (part) s.setFocusTarget(part.position); } }} onApplyFix={applyFindingFix} onClose={() => setCheckFindings(null)} /> )}
); } export default function App() { return ( ); }