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 (
+ <>