From fa778edc752bbf41fe8508dd580172ffa8567b60 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Feb 2026 09:39:05 -0700 Subject: [PATCH] feat: rewrite demo page with live rSpace data via useDemoSync Replace all fetch() calls and static fallbacks with real-time WebSocket connection to the shared demo community. All card components now display live Alpine Explorer 2026 data synced across the r* ecosystem. Co-Authored-By: Claude Opus 4.6 --- src/app/demo/demo-content.tsx | 781 +++++++++++++++------------------- src/lib/demo-sync.ts | 221 ++++++++++ 2 files changed, 573 insertions(+), 429 deletions(-) create mode 100644 src/lib/demo-sync.ts diff --git a/src/app/demo/demo-content.tsx b/src/app/demo/demo-content.tsx index e6f618f..eb2c05f 100644 --- a/src/app/demo/demo-content.tsx +++ b/src/app/demo/demo-content.tsx @@ -1,162 +1,29 @@ 'use client' import Link from 'next/link' -import { useEffect, useState, useCallback } from 'react' +import { useCallback, useMemo } from 'react' +import { useDemoSync, type DemoShape } from '@/lib/demo-sync' -const DEMO_SLUG = 'alpine-explorer-2026' +/* ─── Helper: extract shapes by type ──────────────────────────── */ -/* ─── Types ─────────────────────────────────────────────────── */ - -interface Destination { - id: string - name: string - country: string | null - lat: number | null - lng: number | null - arrivalDate: string | null - departureDate: string | null - notes: string | null - sortOrder: number +function shapesByType(shapes: Record, type: string): DemoShape[] { + return Object.values(shapes).filter((s) => s.type === type) } -interface ItineraryItem { - id: string - title: string - date: string | null - category: string - sortOrder: number +function shapeByType(shapes: Record, type: string): DemoShape | undefined { + return Object.values(shapes).find((s) => s.type === type) } -interface Expense { - id: string - description: string - amount: number - currency: string - category: string - paidBy: { id: string; username: string | null } | null - splitType: string -} - -interface PackingItem { - id: string - name: string - category: string | null - packed: boolean - sortOrder: number -} - -interface Collaborator { - id: string - role: string - user: { id: string; username: string | null } -} - -interface TripData { - id: string - title: string - slug: string - description: string | null - startDate: string | null - endDate: string | null - budgetTotal: number | null - budgetCurrency: string - destinations: Destination[] - itineraryItems: ItineraryItem[] - expenses: Expense[] - packingItems: PackingItem[] - collaborators: Collaborator[] -} - -/* ─── Static Fallback Data ──────────────────────────────────── */ +/* ─── Constants ───────────────────────────────────────────────── */ const MEMBER_COLORS = ['bg-teal-500', 'bg-cyan-500', 'bg-blue-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500'] -const FALLBACK_MEMBERS = [ - { name: 'Alex', color: 'bg-teal-500' }, - { name: 'Sam', color: 'bg-cyan-500' }, - { name: 'Jordan', color: 'bg-blue-500' }, - { name: 'Riley', color: 'bg-violet-500' }, - { name: 'Casey', color: 'bg-amber-500' }, - { name: 'Morgan', color: 'bg-rose-500' }, -] - -const FALLBACK_PACKING = [ - { id: 'f1', name: 'Hiking boots (broken in!)', packed: true }, - { id: 'f2', name: 'Rain jacket & layers', packed: true }, - { id: 'f3', name: 'Headlamp + spare batteries', packed: true }, - { id: 'f4', name: 'First aid kit', packed: false }, - { id: 'f5', name: 'Sunscreen SPF 50', packed: true }, - { id: 'f6', name: 'Trekking poles', packed: false }, - { id: 'f7', name: 'Refillable water bottle', packed: true }, - { id: 'f8', name: 'Via ferrata gloves', packed: false }, -] - -const FALLBACK_RULES = [ - 'Majority vote on daily activities', - 'Shared expenses split equally', - 'Quiet hours after 10pm in huts', - 'Everyone carries their own pack', -] - -const FALLBACK_CALENDAR: Record = { - 6: [{ label: 'Fly to Geneva', color: 'bg-teal-500' }], - 7: [{ label: 'Chamonix check-in', color: 'bg-teal-500' }], - 8: [{ label: 'Mont Blanc hike', color: 'bg-emerald-500' }], - 9: [{ label: 'Rest / explore town', color: 'bg-slate-500' }], - 10: [{ label: 'Mer de Glace trek', color: 'bg-emerald-500' }], - 11: [{ label: 'Via ferrata', color: 'bg-amber-500' }], - 12: [{ label: 'Train to Zermatt', color: 'bg-cyan-500' }], - 13: [{ label: 'Matterhorn viewpoint', color: 'bg-emerald-500' }], - 14: [{ label: 'Mountain biking', color: 'bg-violet-500' }], - 15: [{ label: 'Gorner Gorge hike', color: 'bg-emerald-500' }], - 16: [{ label: 'Paragliding!', color: 'bg-rose-500' }], - 17: [{ label: 'Travel to Dolomites', color: 'bg-cyan-500' }], - 18: [{ label: 'Tre Cime circuit', color: 'bg-emerald-500' }], - 19: [{ label: 'Lake Braies kayaking', color: 'bg-blue-500' }], - 20: [{ label: 'Fly home', color: 'bg-teal-500' }], -} - -const FALLBACK_POLLS = [ - { - question: 'Day 5 Activity?', - options: [ - { label: 'Via Ferrata', votes: 4, color: 'bg-amber-500' }, - { label: 'Kayaking', votes: 1, color: 'bg-blue-500' }, - { label: 'Rest day', votes: 1, color: 'bg-slate-500' }, - ], - totalVotes: 6, - }, - { - question: 'Dinner tonight?', - options: [ - { label: 'Fondue place', votes: 3, color: 'bg-amber-500' }, - { label: 'Pizza by the lake', votes: 2, color: 'bg-rose-500' }, - { label: 'Cook at Airbnb', votes: 1, color: 'bg-emerald-500' }, - ], - totalVotes: 6, - }, -] - -const FALLBACK_EXPENSES = [ - { desc: 'Geneva → Chamonix shuttle', who: 'Alex', amount: 186, split: 6 }, - { desc: 'Mountain hut (2 nights)', who: 'Sam', amount: 420, split: 6 }, - { desc: 'Via ferrata gear rental', who: 'Jordan', amount: 144, split: 4 }, - { desc: 'Groceries (Zermatt)', who: 'Casey', amount: 93, split: 6 }, - { desc: 'Paragliding deposit', who: 'Riley', amount: 360, split: 3 }, -] - -const FALLBACK_CART = [ - { item: 'Group first-aid kit', target: 85, funded: 85, status: 'Purchased' as const }, - { item: 'Portable water filter', target: 45, funded: 45, status: 'Purchased' as const }, - { item: 'Bear canister (2x)', target: 120, funded: 90, status: 'Funding' as const }, - { item: 'Camp stove + fuel', target: 65, funded: 65, status: 'Purchased' as const }, - { item: 'Drone (group footage)', target: 350, funded: 210, status: 'Funding' as const }, - { item: 'Starlink Mini rental', target: 200, funded: 80, status: 'Funding' as const }, -] - -/* ─── Category → Color mapping for calendar ─────────────────── */ - const CATEGORY_COLORS: Record = { + travel: 'bg-teal-500', + hike: 'bg-emerald-500', + adventure: 'bg-amber-500', + rest: 'bg-slate-500', + culture: 'bg-violet-500', FLIGHT: 'bg-teal-500', TRANSPORT: 'bg-cyan-500', ACCOMMODATION: 'bg-teal-500', @@ -166,18 +33,26 @@ const CATEGORY_COLORS: Record = { OTHER: 'bg-slate-500', } -function itineraryToCalendar(items: ItineraryItem[]): Record { - const cal: Record = {} - for (const item of items) { - if (!item.date) continue - const day = new Date(item.date).getUTCDate() - if (!cal[day]) cal[day] = [] - cal[day].push({ - label: item.title, - color: CATEGORY_COLORS[item.category] || 'bg-slate-500', - }) - } - return cal +const POLL_OPTION_COLORS = ['bg-amber-500', 'bg-blue-500', 'bg-emerald-500', 'bg-rose-500'] + +const FALLBACK_RULES = [ + 'Majority vote on daily activities', + 'Shared expenses split equally', + 'Quiet hours after 10pm in huts', + 'Everyone carries their own pack', +] + +/* ─── Loading Skeleton ────────────────────────────────────────── */ + +function LoadingSkeleton() { + return ( +
+
+
+
+
+
+ ) } /* ─── Card Wrapper ──────────────────────────────────────────── */ @@ -232,24 +107,23 @@ function CardWrapper({ /* ─── Card: rMaps ───────────────────────────────────────────── */ -function RMapsCard({ destinations }: { destinations?: Destination[] }) { - // Map destinations to SVG positions (evenly spaced across SVG width) - const pins = destinations && destinations.length > 0 +function RMapsCard({ destinations }: { destinations: DemoShape[]; live: boolean }) { + const pins = destinations.length > 0 ? destinations.map((d, i) => ({ - name: d.name, - country: d.country, + name: d.destName as string, + country: d.country as string, cx: 160 + i * 245, cy: 180 - i * 20, color: ['#14b8a6', '#06b6d4', '#8b5cf6'][i] || '#94a3b8', stroke: ['#0d9488', '#0891b2', '#7c3aed'][i] || '#64748b', dates: d.arrivalDate && d.departureDate - ? `${new Date(d.arrivalDate).toLocaleDateString('en', { month: 'short', day: 'numeric' })}–${new Date(d.departureDate).getUTCDate()}` + ? `${new Date(d.arrivalDate as string).toLocaleDateString('en', { month: 'short', day: 'numeric' })}–${new Date(d.departureDate as string).getUTCDate()}` : '', })) : [ - { name: 'Chamonix', country: 'France', cx: 160, cy: 180, color: '#14b8a6', stroke: '#0d9488', dates: 'Jul 6–11' }, - { name: 'Zermatt', country: 'Switzerland', cx: 430, cy: 150, color: '#06b6d4', stroke: '#0891b2', dates: 'Jul 12–16' }, - { name: 'Dolomites', country: 'Italy', cx: 650, cy: 140, color: '#8b5cf6', stroke: '#7c3aed', dates: 'Jul 17–20' }, + { name: 'Chamonix', country: 'France', cx: 160, cy: 180, color: '#14b8a6', stroke: '#0d9488', dates: 'Jul 6–10' }, + { name: 'Zermatt', country: 'Switzerland', cx: 430, cy: 150, color: '#06b6d4', stroke: '#0891b2', dates: 'Jul 10–14' }, + { name: 'Dolomites', country: 'Italy', cx: 650, cy: 140, color: '#8b5cf6', stroke: '#7c3aed', dates: 'Jul 14–20' }, ] const routePath = pins.length >= 3 @@ -257,7 +131,7 @@ function RMapsCard({ destinations }: { destinations?: Destination[] }) { : 'M160 180 C250 160, 350 200, 430 150 C510 100, 560 160, 650 140' return ( - + 0}>
{/* Mountain silhouettes */} @@ -311,43 +185,47 @@ function RMapsCard({ destinations }: { destinations?: Destination[] }) { function RNotesCard({ packingItems, - tripId, + live, onTogglePacked, }: { - packingItems: { id: string; name: string; packed: boolean }[] - tripId: string | null - onTogglePacked: (itemId: string) => void + packingItems: { name: string; packed: boolean; category: string }[] + live: boolean + onTogglePacked: (index: number) => void }) { return ( - +

Packing Checklist

-
    - {packingItems.map((item) => ( -
  • - - - {item.name} - -
  • - ))} -
+ {packingItems.length === 0 ? ( + + ) : ( +
    + {packingItems.map((item, idx) => ( +
  • + + + {item.name} + +
  • + ))} +
+ )}
@@ -383,7 +261,7 @@ function RCalCard({ calendarEvents, live }: { calendarEvents: Record

July 2026

- {tripEnd - tripStart + 1} days • 3 countries + {tripEnd - tripStart + 1} days
@@ -421,9 +299,8 @@ function RCalCard({ calendarEvents, live }: { calendarEvents: Record Travel Hiking Adventure - Biking - Extreme - Water + Culture + Rest Transit
@@ -433,35 +310,45 @@ function RCalCard({ calendarEvents, live }: { calendarEvents: Record -
- {polls.map((poll) => ( -
-

{poll.question}

-
- {poll.options.map((opt) => { - const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0 - return ( -
-
- {opt.label} - - {opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%) - + + {polls.length === 0 ? ( + + ) : ( +
+ {polls.map((poll) => ( +
+

{poll.question}

+
+ {poll.options.map((opt) => { + const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0 + return ( +
+
+ {opt.label} + + {opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%) + +
+
+
+
-
-
-
-
- ) - })} + ) + })} +
+

{poll.totalVotes} votes cast

-

{poll.totalVotes} votes cast

-
- ))} -
+ ))} +
+ )} ) } @@ -493,41 +380,47 @@ function RFundsCard({
-

€{totalSpent.toLocaleString()}

+

{totalSpent > 0 ? `€${totalSpent.toLocaleString()}` : '...'}

Total group spending

-
-

Recent

-
- {expenses.slice(0, 4).map((e) => ( -
-
-

{e.desc}

-

{e.who} • split {e.split} ways

-
- €{e.amount} + {expenses.length === 0 ? ( + + ) : ( + <> +
+

Recent

+
+ {expenses.slice(0, 4).map((e) => ( +
+
+

{e.desc}

+

{e.who} • split {e.split} ways

+
+ €{e.amount} +
+ ))}
- ))} -
-
+
-
-

Balances

-
- {members.map((m) => { - const bal = balances[m.name] || 0 - return ( -
- {m.name} - = 0 ? 'text-emerald-400' : 'text-rose-400'}> - {bal >= 0 ? '+' : ''}€{Math.round(bal)} - -
- ) - })} -
-
+
+

Balances

+
+ {members.map((m) => { + const bal = balances[m.name] || 0 + return ( +
+ {m.name} + = 0 ? 'text-emerald-400' : 'text-rose-400'}> + {bal >= 0 ? '+' : ''}€{Math.round(bal)} + +
+ ) + })} +
+
+ + )}
) @@ -535,44 +428,55 @@ function RFundsCard({ /* ─── Card: rCart ────────────────────────────────────────────── */ -function RCartCard({ cartItems, live }: { cartItems: typeof FALLBACK_CART; live: boolean }) { +interface CartItemData { + item: string + target: number + funded: number + status: 'Purchased' | 'Funding' +} + +function RCartCard({ cartItems, live }: { cartItems: CartItemData[]; live: boolean }) { const totalFunded = cartItems.reduce((s, i) => s + i.funded, 0) const totalTarget = cartItems.reduce((s, i) => s + i.target, 0) return ( -
-
- €{totalFunded} / €{totalTarget} funded - - {cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased - -
-
-
0 ? Math.round((totalFunded / totalTarget) * 100) : 0}%` }} /> -
+ {cartItems.length === 0 ? ( + + ) : ( +
+
+ €{totalFunded} / €{totalTarget} funded + + {cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased + +
+
+
0 ? Math.round((totalFunded / totalTarget) * 100) : 0}%` }} /> +
-
- {cartItems.map((item) => { - const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0 - return ( -
-
- {item.item} - {item.status === 'Purchased' ? ( - ✓ Bought - ) : ( - €{item.funded}/€{item.target} - )} +
+ {cartItems.map((item) => { + const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0 + return ( +
+
+ {item.item} + {item.status === 'Purchased' ? ( + ✓ Bought + ) : ( + €{item.funded}/€{item.target} + )} +
+
+
+
-
-
-
-
- ) - })} + ) + })} +
-
+ )} ) } @@ -580,139 +484,140 @@ function RCartCard({ cartItems, live }: { cartItems: typeof FALLBACK_CART; live: /* ─── Main Demo Content ─────────────────────────────────────── */ export default function DemoContent() { - const [trip, setTrip] = useState(null) - const [packingItems, setPackingItems] = useState(FALLBACK_PACKING) - const [calendarEvents, setCalendarEvents] = useState(FALLBACK_CALENDAR) - const [polls, setPolls] = useState(FALLBACK_POLLS) - const [expenseData, setExpenseData] = useState(FALLBACK_EXPENSES) - const [cartData, setCartData] = useState(FALLBACK_CART) - const [members, setMembers] = useState(FALLBACK_MEMBERS) - const [liveFlags, setLiveFlags] = useState({ trip: false, polls: false, cart: false }) + const { shapes, updateShape, connected, resetDemo } = useDemoSync() - // Fetch trip data (native rTrips) - useEffect(() => { - fetch(`/api/trips/by-slug/${DEMO_SLUG}`) - .then((r) => { - if (!r.ok) throw new Error(`${r.status}`) - return r.json() + const hasShapes = Object.keys(shapes).length > 0 + + // ── Extract shapes by type ────────────────────────────────── + + const itinerary = useMemo(() => shapeByType(shapes, 'folk-itinerary'), [shapes]) + const destinations = useMemo(() => shapesByType(shapes, 'folk-destination'), [shapes]) + const packingList = useMemo(() => shapeByType(shapes, 'folk-packing-list'), [shapes]) + const pollShapes = useMemo(() => shapesByType(shapes, 'demo-poll'), [shapes]) + const expenseShapes = useMemo(() => shapesByType(shapes, 'demo-expense'), [shapes]) + const cartShapes = useMemo(() => shapesByType(shapes, 'demo-cart-item'), [shapes]) + const budgetShape = useMemo(() => shapeByType(shapes, 'folk-budget'), [shapes]) + + // ── Derived data: members from itinerary travelers ───────── + + const members = useMemo(() => { + const travelers = (itinerary?.travelers ?? []) as string[] + if (travelers.length === 0) return [] + return travelers.map((name, i) => ({ + name, + color: MEMBER_COLORS[i % MEMBER_COLORS.length], + })) + }, [itinerary]) + + // ── Derived data: calendar events from itinerary items ───── + + const calendarEvents = useMemo(() => { + const items = (itinerary?.items ?? []) as { date: string; activity: string; category: string }[] + if (items.length === 0) return {} as Record + + const cal: Record = {} + for (const item of items) { + if (!item.date) continue + // Parse "Jul 6" style dates + const match = item.date.match(/(\d+)/) + if (!match) continue + const day = parseInt(match[1], 10) + if (!cal[day]) cal[day] = [] + cal[day].push({ + label: item.activity, + color: CATEGORY_COLORS[item.category] || 'bg-slate-500', }) - .then((data: TripData) => { - setTrip(data) + } + return cal + }, [itinerary]) - // Packing items - if (data.packingItems?.length > 0) { - setPackingItems(data.packingItems.map((p) => ({ id: p.id, name: p.name, packed: p.packed }))) - } + // ── Derived data: packing items ──────────────────────────── - // Calendar from itinerary - if (data.itineraryItems?.length > 0) { - setCalendarEvents(itineraryToCalendar(data.itineraryItems)) - } + const packingItems = useMemo(() => { + const items = (packingList?.items ?? []) as { name: string; packed: boolean; category: string }[] + return items + }, [packingList]) - // Expenses - if (data.expenses?.length > 0) { - setExpenseData( - data.expenses.map((e) => ({ - desc: e.description, - who: e.paidBy?.username || 'Unknown', - amount: e.amount, - split: 6, // All demo expenses are EQUAL split among 6 - })) - ) - } + // ── Derived data: polls ──────────────────────────────────── - // Members from collaborators - if (data.collaborators?.length > 0) { - setMembers( - data.collaborators.map((c, i) => ({ - name: c.user.username || `User ${i + 1}`, - color: MEMBER_COLORS[i % MEMBER_COLORS.length], - })) - ) - } - - setLiveFlags((f) => ({ ...f, trip: true })) - }) - .catch(() => { - // Keep fallback data - }) - }, []) - - // Fetch rVote polls - useEffect(() => { - fetch(`/api/proxy/rvote?endpoint=proposals&slug=${DEMO_SLUG}`) - .then((r) => { - if (!r.ok) throw new Error(`${r.status}`) - return r.json() - }) - .then((data) => { - if (data.proposals && data.proposals.length > 0) { - // Transform rVote proposals to poll format - setPolls( - data.proposals.slice(0, 2).map((p: Record) => ({ - question: p.title as string, - options: (p.description as string || '').split('\n').filter(Boolean).map((opt: string, i: number) => ({ - label: opt, - votes: Math.max(1, Math.floor(Math.random() * 5)), - color: ['bg-amber-500', 'bg-blue-500', 'bg-emerald-500', 'bg-rose-500'][i % 4], - })), - totalVotes: 6, - })) - ) - setLiveFlags((f) => ({ ...f, polls: true })) - } - }) - .catch(() => { - // Keep fallback polls - }) - }, []) - - // Fetch rCart data - useEffect(() => { - fetch(`/api/proxy/rcart?endpoint=carts&slug=${DEMO_SLUG}`) - .then((r) => { - if (!r.ok) throw new Error(`${r.status}`) - return r.json() - }) - .then((data) => { - if (Array.isArray(data) && data.length > 0) { - setCartData( - data.map((cart: Record) => { - const target = Number(cart.targetAmount) || 100 - const funded = Number(cart.fundedAmount) || 0 - return { - item: cart.name as string, - target, - funded, - status: (funded >= target ? 'Purchased' : 'Funding') as 'Purchased' | 'Funding', - } - }) - ) - setLiveFlags((f) => ({ ...f, cart: true })) - } - }) - .catch(() => { - // Keep fallback cart - }) - }, []) - - // Toggle packing item - const handleTogglePacked = useCallback( - (itemId: string) => { - // Optimistic update - setPackingItems((items) => items.map((i) => (i.id === itemId ? { ...i, packed: !i.packed } : i))) - - // Persist if we have a real trip - if (trip?.id) { - fetch(`/api/trips/${trip.id}/packing/${itemId}`, { method: 'PATCH' }).catch(() => { - // Revert on error - setPackingItems((items) => items.map((i) => (i.id === itemId ? { ...i, packed: !i.packed } : i))) - }) + const polls = useMemo((): PollData[] => { + return pollShapes.map((shape) => { + const options = (shape.options ?? []) as { label: string; votes: number }[] + const totalVotes = options.reduce((sum, o) => sum + o.votes, 0) + return { + question: shape.question as string, + options: options.map((o, i) => ({ + label: o.label, + votes: o.votes, + color: POLL_OPTION_COLORS[i % POLL_OPTION_COLORS.length], + })), + totalVotes, } + }) + }, [pollShapes]) + + // ── Derived data: expenses ───────────────────────────────── + + const expenses = useMemo(() => { + return expenseShapes.map((shape) => ({ + desc: shape.description as string, + who: shape.paidBy as string, + amount: shape.amount as number, + split: members.length || 4, + })) + }, [expenseShapes, members.length]) + + // ── Derived data: cart items ─────────────────────────────── + + const cartItems = useMemo((): CartItemData[] => { + return cartShapes.map((shape) => { + const price = shape.price as number + const funded = shape.funded as number + return { + item: shape.name as string, + target: price, + funded, + status: (funded >= price ? 'Purchased' : 'Funding') as 'Purchased' | 'Funding', + } + }) + }, [cartShapes]) + + // ── Derived data: budget info ────────────────────────────── + + const budgetTotal = (budgetShape?.budgetTotal as number) || 4000 + + // ── Toggle packing item ──────────────────────────────────── + + const handleTogglePacked = useCallback( + (index: number) => { + if (!packingList) return + const items = [...(packingList.items as { name: string; packed: boolean; category: string }[])] + items[index] = { ...items[index], packed: !items[index].packed } + updateShape(packingList.id, { items }) }, - [trip?.id] + [packingList, updateShape] ) + // ── Reset demo handler ───────────────────────────────────── + + const handleReset = useCallback(async () => { + try { + await resetDemo() + } catch (err) { + console.error('Failed to reset demo:', err) + } + }, [resetDemo]) + + // ── Header info ──────────────────────────────────────────── + + const tripTitle = (itinerary?.tripTitle as string) || 'Alpine Explorer 2026' + const startDate = (itinerary?.startDate as string) || '' + const endDate = (itinerary?.endDate as string) || '' + + const dateRange = startDate && endDate + ? `${new Date(startDate).toLocaleDateString('en', { month: 'short', day: 'numeric' })}–${new Date(endDate).toLocaleDateString('en', { month: 'short', day: 'numeric' })}, 2026` + : 'Jul 6–20, 2026' + return (
{/* Nav */} @@ -728,12 +633,26 @@ export default function DemoContent() { / Demo
- - Plan Your Own Trip - +
+ {/* Connection indicator */} + + + {connected ? 'Connected to rSpace' : 'Connecting...'} + + + + Plan Your Own Trip + +
@@ -741,16 +660,18 @@ export default function DemoContent() {

- {trip?.title || 'Alpine Explorer 2026'} + {tripTitle}

- {trip?.description || 'Chamonix → Zermatt → Dolomites'} + {destinations.length > 0 + ? destinations.map((d) => d.destName as string).join(' → ') + : 'Chamonix → Zermatt → Dolomites'}

- 📅 Jul 6–20, 2026 - 💶 ~€{trip?.budgetTotal?.toLocaleString() || '4,500'} budget - 🏔️ 3 countries - {liveFlags.trip && ( + 📅 {dateRange} + 💶 ~€{budgetTotal.toLocaleString()} budget + 🏔️ {destinations.length > 0 ? `${new Set(destinations.map((d) => d.country as string)).size} countries` : '3 countries'} + {hasShapes && ( Live data @@ -760,7 +681,7 @@ export default function DemoContent() { {/* Member avatars */}
- {members.map((m) => ( + {(members.length > 0 ? members : [{ name: 'Loading', color: 'bg-slate-600' }]).map((m) => (
))} - {members.length} explorers + {members.length > 0 && ( + {members.length} explorers + )}
@@ -786,12 +709,12 @@ export default function DemoContent() { {/* Canvas Grid */}
- - - - - - + + + + + +
diff --git a/src/lib/demo-sync.ts b/src/lib/demo-sync.ts new file mode 100644 index 0000000..6487586 --- /dev/null +++ b/src/lib/demo-sync.ts @@ -0,0 +1,221 @@ +/** + * useDemoSync — lightweight React hook for real-time demo data via rSpace + * + * Connects to rSpace WebSocket in JSON mode (no Automerge bundle needed). + * All demo pages share the "demo" community, so changes in one app + * propagate to every other app viewing the same shapes. + * + * Usage: + * const { shapes, updateShape, deleteShape, connected, resetDemo } = useDemoSync({ + * filter: ['folk-note', 'folk-notebook'], // optional: only these shape types + * }); + */ + +import { useEffect, useRef, useState, useCallback } from 'react'; + +export interface DemoShape { + type: string; + id: string; + x: number; + y: number; + width: number; + height: number; + rotation: number; + [key: string]: unknown; +} + +interface UseDemoSyncOptions { + /** Community slug (default: 'demo') */ + slug?: string; + /** Only subscribe to these shape types */ + filter?: string[]; + /** rSpace server URL (default: auto-detect based on environment) */ + serverUrl?: string; +} + +interface UseDemoSyncReturn { + /** Current shapes (filtered if filter option set) */ + shapes: Record; + /** Update a shape by ID (partial update merged with existing) */ + updateShape: (id: string, data: Partial) => void; + /** Delete a shape by ID */ + deleteShape: (id: string) => void; + /** Whether WebSocket is connected */ + connected: boolean; + /** Reset demo to seed state */ + resetDemo: () => Promise; +} + +const DEFAULT_SLUG = 'demo'; +const RECONNECT_BASE_MS = 1000; +const RECONNECT_MAX_MS = 30000; +const PING_INTERVAL_MS = 30000; + +function getDefaultServerUrl(): string { + if (typeof window === 'undefined') return 'https://rspace.online'; + // In development, use localhost + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + return `http://${window.location.hostname}:3000`; + } + return 'https://rspace.online'; +} + +export function useDemoSync(options?: UseDemoSyncOptions): UseDemoSyncReturn { + const slug = options?.slug ?? DEFAULT_SLUG; + const filter = options?.filter; + const serverUrl = options?.serverUrl ?? getDefaultServerUrl(); + + const [shapes, setShapes] = useState>({}); + const [connected, setConnected] = useState(false); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const pingTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + // Stable filter reference for use in callbacks + const filterRef = useRef(filter); + filterRef.current = filter; + + const applyFilter = useCallback((allShapes: Record): Record => { + const f = filterRef.current; + if (!f || f.length === 0) return allShapes; + const filtered: Record = {}; + for (const [id, shape] of Object.entries(allShapes)) { + if (f.includes(shape.type)) { + filtered[id] = shape; + } + } + return filtered; + }, []); + + const connect = useCallback(() => { + if (!mountedRef.current) return; + + // Build WebSocket URL + const wsProtocol = serverUrl.startsWith('https') ? 'wss' : 'ws'; + const host = serverUrl.replace(/^https?:\/\//, ''); + const wsUrl = `${wsProtocol}://${host}/ws/${slug}?mode=json`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (!mountedRef.current) return; + setConnected(true); + reconnectAttemptRef.current = 0; + + // Start ping keepalive + pingTimerRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } + }, PING_INTERVAL_MS); + }; + + ws.onmessage = (event) => { + if (!mountedRef.current) return; + try { + const msg = JSON.parse(event.data); + if (msg.type === 'snapshot' && msg.shapes) { + setShapes(applyFilter(msg.shapes)); + } + // pong and error messages are silently handled + } catch { + // ignore parse errors + } + }; + + ws.onclose = () => { + if (!mountedRef.current) return; + setConnected(false); + cleanup(); + scheduleReconnect(); + }; + + ws.onerror = () => { + // onclose will fire after onerror, so reconnect is handled there + }; + }, [slug, serverUrl, applyFilter]); + + const cleanup = useCallback(() => { + if (pingTimerRef.current) { + clearInterval(pingTimerRef.current); + pingTimerRef.current = null; + } + }, []); + + const scheduleReconnect = useCallback(() => { + if (!mountedRef.current) return; + const attempt = reconnectAttemptRef.current; + const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS); + reconnectAttemptRef.current = attempt + 1; + + reconnectTimerRef.current = setTimeout(() => { + if (mountedRef.current) connect(); + }, delay); + }, [connect]); + + // Connect on mount + useEffect(() => { + mountedRef.current = true; + connect(); + + return () => { + mountedRef.current = false; + cleanup(); + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + if (wsRef.current) { + wsRef.current.onclose = null; // prevent reconnect on unmount + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [connect, cleanup]); + + const updateShape = useCallback((id: string, data: Partial) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // Optimistic local update + setShapes((prev) => { + const existing = prev[id]; + if (!existing) return prev; + const updated = { ...existing, ...data, id }; + const f = filterRef.current; + if (f && f.length > 0 && !f.includes(updated.type)) return prev; + return { ...prev, [id]: updated }; + }); + + // Send to server + ws.send(JSON.stringify({ type: 'update', id, data: { ...data, id } })); + }, []); + + const deleteShape = useCallback((id: string) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // Optimistic local delete + setShapes((prev) => { + const { [id]: _, ...rest } = prev; + return rest; + }); + + ws.send(JSON.stringify({ type: 'delete', id })); + }, []); + + const resetDemo = useCallback(async () => { + const res = await fetch(`${serverUrl}/api/communities/demo/reset`, { method: 'POST' }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Reset failed: ${res.status} ${body}`); + } + // The server will broadcast new snapshot via WebSocket + }, [serverUrl]); + + return { shapes, updateShape, deleteShape, connected, resetDemo }; +}