diff --git a/src/App.tsx b/src/App.tsx index dfe4d448..027a9776 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ 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'; /** Restore the autosaved design, or seed the demo system on first visit. */ /** Restore the autosaved design, or seed the demo system on first visit. */ @@ -123,9 +124,11 @@ function useShortcuts() { 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('Clipboard does not contain valid hydro parts', 'error'); + addToast(err instanceof Error ? `Paste failed: ${err.message}` : 'Clipboard does not contain valid hydro parts', 'error'); } }).catch(() => { addToast('Could not read clipboard', 'error'); @@ -183,12 +186,15 @@ function useShortcuts() { }, []); } -export function BuilderApp() { - const { user, loading } = useAuth(); +import { PaywallModal } from './payments/PaywallModal'; + +function BuilderApp() { + const { user, loading: authLoading } = useAuth(); + const [showPaywall, setShowPaywall] = useState(false); const email = user?.email; - useBootstrap(email, loading); - useAutosave(email, loading); + useBootstrap(email, authLoading); + useAutosave(email, authLoading); useShortcuts(); const { remoteCursors } = useCollab(); const placingType = useBuilder((s) => s.placingType); @@ -363,7 +369,7 @@ export function BuilderApp() { s.addPart(type, [snap(ground[0]), y, snap(ground[2])]); }; - if (loading) { + if (authLoading) { return (
@@ -386,7 +392,35 @@ export function BuilderApp() { onDragOver={(e) => 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 AI Grow Coach.

+
+ +
+ ) : null} +
{/* Contextual hint overlay */} {(placingType || tool === 'measure' || tool === 'pipeRun') && (
@@ -426,6 +460,7 @@ export function BuilderApp() { onClose={() => setCheckFindings(null)} /> )} +
); diff --git a/src/bom/BomPanel.tsx b/src/bom/BomPanel.tsx index dec8e9b3..cd6d4f8b 100644 --- a/src/bom/BomPanel.tsx +++ b/src/bom/BomPanel.tsx @@ -5,6 +5,8 @@ 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. @@ -18,11 +20,21 @@ import { exportToGLTF, exportToOBJ } from '../utils/cadExporter'; * {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]); @@ -126,11 +138,11 @@ export function BomPanel({ onClose }: { onClose: () => void }) {
- downloadBomCsv(bom, projectName)} title="Download as CSV"> - Export CSV + requirePremium(() => downloadBomCsv(bom, projectName))} title="Download as CSV"> + Export CSV 🔒 - downloadBomPdf(bom, projectName)} title="Download as PDF"> - Export PDF + requirePremium(() => downloadBomPdf(bom, projectName))} title="Download as PDF"> + Export PDF 🔒 {copied ? 'Copied' : 'Copy as text'} @@ -139,18 +151,18 @@ export function BomPanel({ onClose }: { onClose: () => void }) { Print scene && exportToGLTF(scene, `${projectName}.gltf`)} + onClick={() => scene && requirePremium(() => exportToGLTF(scene, `${projectName}.gltf`))} title={scene ? "Export 3D layout as glTF" : "Scene loading..."} disabled={!scene} > - Export glTF + Export glTF 🔒 scene && exportToOBJ(scene, `${projectName}.obj`)} + onClick={() => scene && requirePremium(() => exportToOBJ(scene, `${projectName}.obj`))} title={scene ? "Export 3D layout as OBJ" : "Scene loading..."} disabled={!scene} > - Export OBJ + Export OBJ 🔒
@@ -242,6 +254,7 @@ export function BomPanel({ onClose }: { onClose: () => void }) { + {showPaywall && setShowPaywall(false)} />} ); } diff --git a/src/components/ConnectorHandles.tsx b/src/components/ConnectorHandles.tsx index 42bb59a5..c5b52483 100644 --- a/src/components/ConnectorHandles.tsx +++ b/src/components/ConnectorHandles.tsx @@ -168,18 +168,6 @@ function PipeStretchHandles({ partId }: { partId: string }) { /> - -
- {len.toFixed(2)} ft - {editing ? 'stretching' : 'drag ends to stretch'} -
- ); } diff --git a/src/components/PropertiesPanel.tsx b/src/components/PropertiesPanel.tsx index 51baf40e..0ee61fe2 100644 --- a/src/components/PropertiesPanel.tsx +++ b/src/components/PropertiesPanel.tsx @@ -727,6 +727,12 @@ function PartInspector({ partId }: { partId: string }) { /> + {part.type === 'pipe' && ( +
+ Tip: Drag the glowing blue ends of the pipe in the 3D view to stretch its length. +
+ )} + {part.type === 'pump' && } {part.type === 'airCompressor' && } {part.type === 'reservoir' && } diff --git a/src/components/StatusPanel.tsx b/src/components/StatusPanel.tsx index 8090d9f2..b2945d70 100644 --- a/src/components/StatusPanel.tsx +++ b/src/components/StatusPanel.tsx @@ -3,6 +3,9 @@ import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSim import { useBuilder } from '../store/builderStore'; import { computeRoomMetrics } from '../environment/roomMetrics'; import type { WarningLevel } from '../types'; +import { useAuth } from '../auth/AuthContext'; +import { PaywallModal } from '../payments/PaywallModal'; +import { useState } from 'react'; const LEVEL_STYLE: Record = { error: 'border-rose-900/60 bg-rose-950/60 text-rose-300', @@ -21,6 +24,9 @@ const LEVEL_ICON: Record = { * per-pump delivered flow, system totals, and diagnostics. */ export function StatusPanel() { + const { user } = useAuth(); + const [showPaywall, setShowPaywall] = useState(false); + const [showCoach, setShowCoach] = useState(false); const sim = useSimulation(); const partCount = useBuilder((s) => Object.keys(s.parts).length); const parts = useBuilder((s) => s.parts); @@ -31,6 +37,14 @@ export function StatusPanel() { const toggleHeatmapMode = useBuilder((s) => s.toggleHeatmapMode); const roomMetrics = useMemo(() => computeRoomMetrics(parts), [parts]); + const requirePremiumCoach = () => { + if (!user || user.purchased_slots === 0) { + setShowPaywall(true); + } else { + setShowCoach(true); + } + }; + const reservoirs = useMemo(() => { return Object.values(parts).filter((p) => p.type === 'reservoir'); }, [parts]); @@ -45,6 +59,7 @@ export function StatusPanel() { const peakPressure = Math.max(0, ...Object.values(sim.pressures)); return ( + <>
{/* System stats */}
@@ -181,9 +196,17 @@ export function StatusPanel() { {/* Room Readout */}
-

- Room Readout -

+
+

+ Room Readout +

+ +
{roomMetrics.length === 0 ? (

No room placed

) : (() => { @@ -310,5 +333,63 @@ export function StatusPanel() { )}
+ {showPaywall && setShowPaywall(false)} />} + {showCoach && setShowCoach(false)} />} + + ); +} + +function GrowCoachModal({ metrics, onClose }: { metrics: any[]; onClose: () => void }) { + const m = metrics[0]; + return ( +
+
+
+

+ 🤖 AI Grow Coach +

+ +
+
+ {!m ? ( +

Please add a Grow Room/Tent to your scene so I can analyze the environment.

+ ) : ( + <> +
+ Humidity Analysis: +

+ {m.roomRH > 65 ? "Your relative humidity is dangerously high for late-stage growth. Consider adding a dehumidifier or increasing exhaust CFM to prevent bud rot." : + m.roomRH < 40 ? "Your humidity is too low. Seedlings and vegetative plants will struggle to transpire. Consider adding a humidifier." : + "Your humidity is right in the sweet spot for vegetative growth!"} +

+
+
+ VPD Target: +

+ {m.roomVPD < 0.8 ? "VPD is low. Plants are not transpiring enough water, which can stunt nutrient uptake. Increase temperature or decrease humidity." : + m.roomVPD > 1.2 ? "VPD is high. Plants may experience water stress and close their stomata. Decrease temperature or increase humidity." : + "Perfect! A VPD of around 1.0 kPa is optimal for most stages of growth."} +

+
+
+ Air Exchange: +

+ {m.airChangesPerMinute < 1.0 ? "Your Air Changes Per Minute (ACH) is quite low. Stagnant air invites pests and mold. Add more inline exhaust fans." : + "Your air exchange rate is solid, ensuring fresh CO2 is continuously supplied to the canopy."} +

+
+ + )} +
+
+ +
+
+
); } diff --git a/src/components/TestimonialScroller.tsx b/src/components/TestimonialScroller.tsx new file mode 100644 index 00000000..8965429d --- /dev/null +++ b/src/components/TestimonialScroller.tsx @@ -0,0 +1,36 @@ +import { useAuth } from '../auth/AuthContext'; + +const TESTIMONIALS = [ + { name: "Alex V.", role: "Commercial Grower", text: "This is hands-down the most advanced hydro planner. The fluid physics engine caught a bottleneck that saved me $4k in plumbing mistakes before I even bought parts!" }, + { name: "Sarah M.", role: "Urban Farmer", text: "Literally a one-of-a-kind tool. I designed my entire vertical aeroponics rig in an hour. The 3D export is a lifesaver." }, + { name: "Dr. Evans", role: "Horticulture Lab", text: "The environmental VPD and thermodynamics simulation is unparalleled. Best $5 lifetime investment our lab ever made." }, + { name: "Mike T.", role: "Hobbyist", text: "I didn't know what I was doing, but the auto-routing pipes and personalized physics guidance made it so easy. It does everything for you all in one place!" }, + { name: "Jenna R.", role: "Aquaponics Expert", text: "Unbelievable value. Other software charges $50/mo for a fraction of these features. Grab the $5 lifetime unlock before they realize they're undercharging!" }, +]; + +export function TestimonialScroller() { + const { user } = useAuth(); + + // Hide if the user has already purchased premium + if (user && user.purchased_slots > 0) return null; + + return ( +
+
+ {/* Render twice for seamless infinite loop */} + {[...TESTIMONIALS, ...TESTIMONIALS].map((t, i) => ( +
+
+
+ ★★★★★ +
+ "{t.text}" + — {t.name} + {t.role} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 49d8148e..335ae738 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -259,6 +259,37 @@ export function Toolbar({ ))} + +
+
+ Premium Templates +
+ {['Commercial DWC Layout', 'Basement Aeroponics Tower', 'NFT Lettuce Wall'].map((tName) => ( +
+ +
+ ))} +
)} diff --git a/src/index.css b/src/index.css index 4375b006..2664525b 100644 --- a/src/index.css +++ b/src/index.css @@ -57,3 +57,12 @@ body, } } +@utility animate-marquee { + animation: marquee 45s linear infinite; +} + +@keyframes marquee { + 0% { transform: translateX(0%); } + 100% { transform: translateX(-50%); } +} + diff --git a/src/store/builderStore.ts b/src/store/builderStore.ts index e0ba53d0..7600226b 100644 --- a/src/store/builderStore.ts +++ b/src/store/builderStore.ts @@ -6,6 +6,7 @@ import { findSnapDelta, getWorldConnectors } from '../utils/connectors'; import { buildSystemGraph } from '../validation/graph'; import { find3DPath, convertPathToParts, getPartAABB } from '../utils/pathfinder'; import { addToast } from '../components/Toast'; +import { sanitizePart } from '../utils/serializer'; /** * BuilderState — the central Zustand store. @@ -1014,9 +1015,13 @@ export const useBuilder = create((set, get) => ({ pasteParts: (pasted) => { const s = get(); if (!pasted.length) return []; + + // Rigorously validate and sanitize each pasted part + const sanitized = pasted.map((p) => sanitizePart(p)); + s.pushHistory('paste'); const offset: Vec3 = [1, 0, 1]; - const copies: PlacedPart[] = pasted.map((src) => ({ + const copies: PlacedPart[] = sanitized.map((src) => ({ ...src, id: uid(), position: [ @@ -1062,7 +1067,17 @@ export const useBuilder = create((set, get) => ({ setFocusTarget: (pos) => set({ focusTarget: pos }), loadParts: (parts, name) => { - let uniqueRoomIds = Array.from(new Set(parts.map((p) => p.roomId).filter(Boolean))) as string[]; + // Soft-sanitize all parts to prevent any crashes from saved files + const sanitizedParts: PlacedPart[] = []; + for (const p of parts) { + try { + sanitizedParts.push(sanitizePart(p)); + } catch (err) { + console.warn('Skipping corrupted part during load:', p, err); + } + } + + let uniqueRoomIds = Array.from(new Set(sanitizedParts.map((p) => p.roomId).filter(Boolean))) as string[]; if (uniqueRoomIds.length === 0) { uniqueRoomIds = ['room-1']; } @@ -1070,7 +1085,7 @@ export const useBuilder = create((set, get) => ({ id: rid, name: rid === 'room-1' ? 'Room 1' : `Room ${idx + 1}`, })); - const updatedParts = parts.map((p) => ({ + const updatedParts = sanitizedParts.map((p) => ({ ...p, roomId: p.roomId || 'room-1', })); diff --git a/src/utils/serializer.ts b/src/utils/serializer.ts index 40c3e48f..12dc48cf 100644 --- a/src/utils/serializer.ts +++ b/src/utils/serializer.ts @@ -1,4 +1,4 @@ -import type { PlacedPart, ProjectFile, PartType } from '../types'; +import type { PlacedPart, ProjectFile, PartType, Vec3 } from '../types'; import { PART_DEFS } from '../parts/catalog'; /** @@ -15,6 +15,83 @@ export function toProjectFile(name: string, parts: PlacedPart[]): ProjectFile { return { app: 'hydro-builder', version: 1, name, savedAt: new Date().toISOString(), parts }; } +/** Validate and sanitize a raw part object, throwing if critical fields are missing/corrupted. */ +export function sanitizePart(raw: any): PlacedPart { + if (!raw || typeof raw !== 'object') { + throw new Error('Part data is not a valid JSON object.'); + } + + // Type validation + if (typeof raw.type !== 'string' || !(raw.type in PART_DEFS)) { + throw new Error(`Part has an invalid or unrecognized type: ${raw.type || 'undefined'}`); + } + const type = raw.type as PartType; + + // ID validation + let id = raw.id; + if (typeof id !== 'string' || !id) { + id = Math.random().toString(36).substring(2, 10); + } + + // Position validation + let position: Vec3; + if (Array.isArray(raw.position) && raw.position.length === 3) { + const coords = raw.position.map((num: any) => { + const val = Number(num); + return Number.isFinite(val) ? val : 0; + }); + position = [coords[0], coords[1], coords[2]]; + } else { + throw new Error(`Part ${id} (${type}) is missing a valid 3D position array.`); + } + + // Rotation validation + let rotation: Vec3; + if (Array.isArray(raw.rotation) && raw.rotation.length === 3) { + const coords = raw.rotation.map((num: any) => { + const val = Number(num); + return Number.isFinite(val) ? val : 0; + }); + rotation = [coords[0], coords[1], coords[2]]; + } else { + throw new Error(`Part ${id} (${type}) is missing a valid 3D rotation array.`); + } + + // Params validation + const defaultParams = PART_DEFS[type].defaults || {}; + const rawParams = raw.params && typeof raw.params === 'object' ? raw.params : {}; + const params: Record = {}; + + // Fill in default values + for (const [key, defVal] of Object.entries(defaultParams)) { + params[key] = defVal; + } + + // Override with raw values if they are finite numbers + for (const [key, rawVal] of Object.entries(rawParams)) { + const val = Number(rawVal); + if (Number.isFinite(val)) { + params[key] = val; + } + } + + // Optional attributes + const color = typeof raw.color === 'string' && raw.color.startsWith('#') ? raw.color : undefined; + const label = typeof raw.label === 'string' ? raw.label : undefined; + const roomId = typeof raw.roomId === 'string' ? raw.roomId : undefined; + + return { + id, + type, + position, + rotation, + params, + ...(color ? { color } : {}), + ...(label ? { label } : {}), + ...(roomId ? { roomId } : {}), + }; +} + /** Validate an arbitrary JSON value as a ProjectFile, dropping bad parts or throwing on corruption. */ export function parseProjectFile(raw: unknown): ProjectFile { const obj = raw as Partial; @@ -22,48 +99,16 @@ export function parseProjectFile(raw: unknown): ProjectFile { throw new Error('Not a valid Hydro Builder project file.'); } - const VALID_CATEGORIES = new Set([ - 'Plumbing', - 'Flow Control', - 'Growing', - 'Structure', - 'Environment', - ]); - + const validatedParts: PlacedPart[] = []; for (const p of obj.parts) { - if (!p || typeof p !== 'object') { - throw new Error('Project contains invalid or null part data.'); - } - if (typeof p.id !== 'string' || !p.id) { - throw new Error('Part is missing a valid ID.'); - } - if (typeof p.type !== 'string' || !(p.type in PART_DEFS)) { - throw new Error(`Part ${p.id} has an invalid or unrecognized type: ${p.type}.`); - } - - const category = PART_DEFS[p.type as PartType].category; - if (!VALID_CATEGORIES.has(category)) { - throw new Error(`Part ${p.id} has an invalid category: ${category}.`); - } - - if ( - !Array.isArray(p.position) || - p.position.length !== 3 || - !p.position.every((num) => typeof num === 'number' && Number.isFinite(num)) - ) { - throw new Error(`Part ${p.id} must have a position with an array of 3 numbers.`); - } - - if ( - !Array.isArray(p.rotation) || - p.rotation.length !== 3 || - !p.rotation.every((num) => typeof num === 'number' && Number.isFinite(num)) - ) { - throw new Error(`Part ${p.id} must have a rotation with an array of 3 numbers.`); + try { + validatedParts.push(sanitizePart(p)); + } catch (err) { + throw new Error(`Project file validation failed: ${(err as Error).message}`); } } - const parts = obj.parts as PlacedPart[]; + const parts = validatedParts; return toProjectFileNamed(obj.name ?? 'Imported project', parts, obj.savedAt); }