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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:39:05 -07:00
parent 6c6fb8a857
commit fa778edc75
2 changed files with 573 additions and 429 deletions

View File

@ -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<string, DemoShape>, 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<string, DemoShape>, 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<number, { label: string; color: string }[]> = {
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<string, string> = {
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<string, string> = {
OTHER: 'bg-slate-500',
}
function itineraryToCalendar(items: ItineraryItem[]): Record<number, { label: string; color: string }[]> {
const cal: Record<number, { label: string; color: string }[]> = {}
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 (
<div className="animate-pulse space-y-3">
<div className="h-4 bg-slate-700/50 rounded w-3/4" />
<div className="h-4 bg-slate-700/50 rounded w-1/2" />
<div className="h-4 bg-slate-700/50 rounded w-2/3" />
<div className="h-4 bg-slate-700/50 rounded w-1/3" />
</div>
)
}
/* ─── 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 611' },
{ name: 'Zermatt', country: 'Switzerland', cx: 430, cy: 150, color: '#06b6d4', stroke: '#0891b2', dates: 'Jul 1216' },
{ name: 'Dolomites', country: 'Italy', cx: 650, cy: 140, color: '#8b5cf6', stroke: '#7c3aed', dates: 'Jul 1720' },
{ name: 'Chamonix', country: 'France', cx: 160, cy: 180, color: '#14b8a6', stroke: '#0d9488', dates: 'Jul 610' },
{ name: 'Zermatt', country: 'Switzerland', cx: 430, cy: 150, color: '#06b6d4', stroke: '#0891b2', dates: 'Jul 1014' },
{ name: 'Dolomites', country: 'Italy', cx: 650, cy: 140, color: '#8b5cf6', stroke: '#7c3aed', dates: 'Jul 1420' },
]
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 (
<CardWrapper icon="🗺️" title="Route Map" service="rMaps" href="rmaps.online" span={2} live={!!destinations}>
<CardWrapper icon="🗺️" title="Route Map" service="rMaps" href="rmaps.online" span={2} live={destinations.length > 0}>
<div className="relative w-full h-64 rounded-xl bg-slate-900/60 overflow-hidden">
<svg viewBox="0 0 800 300" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
{/* 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 (
<CardWrapper icon="📝" title="Trip Notes" service="rNotes" href="rnotes.online" live={!!tripId}>
<CardWrapper icon="📝" title="Trip Notes" service="rNotes" href="rnotes.online" live={live}>
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Packing Checklist
</h4>
<ul className="space-y-1.5">
{packingItems.map((item) => (
<li key={item.id} className="flex items-center gap-2 text-sm">
<button
onClick={() => onTogglePacked(item.id)}
className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors cursor-pointer ${
item.packed
? 'bg-teal-500/20 border-teal-500 text-teal-400'
: 'border-slate-600 hover:border-slate-400'
}`}
>
{item.packed && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<span className={item.packed ? 'text-slate-400 line-through' : 'text-slate-200'}>
{item.name}
</span>
</li>
))}
</ul>
{packingItems.length === 0 ? (
<LoadingSkeleton />
) : (
<ul className="space-y-1.5">
{packingItems.map((item, idx) => (
<li key={`${item.name}-${idx}`} className="flex items-center gap-2 text-sm">
<button
onClick={() => onTogglePacked(idx)}
className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors cursor-pointer ${
item.packed
? 'bg-teal-500/20 border-teal-500 text-teal-400'
: 'border-slate-600 hover:border-slate-400'
}`}
>
{item.packed && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<span className={item.packed ? 'text-slate-400 line-through' : 'text-slate-200'}>
{item.name}
</span>
</li>
))}
</ul>
)}
</div>
<div>
@ -383,7 +261,7 @@ function RCalCard({ calendarEvents, live }: { calendarEvents: Record<number, { l
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-slate-200">July 2026</h4>
<span className="text-xs text-slate-400">{tripEnd - tripStart + 1} days 3 countries</span>
<span className="text-xs text-slate-400">{tripEnd - tripStart + 1} days</span>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
@ -421,9 +299,8 @@ function RCalCard({ calendarEvents, live }: { calendarEvents: Record<number, { l
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-teal-500" /> Travel</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-500" /> Hiking</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-500" /> Adventure</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-500" /> Biking</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-rose-500" /> Extreme</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-500" /> Water</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-500" /> Culture</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-slate-500" /> Rest</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-cyan-500" /> Transit</span>
</div>
</div>
@ -433,35 +310,45 @@ function RCalCard({ calendarEvents, live }: { calendarEvents: Record<number, { l
/* ─── Card: rVote ───────────────────────────────────────────── */
function RVoteCard({ polls }: { polls: typeof FALLBACK_POLLS; live: boolean }) {
interface PollData {
question: string
options: { label: string; votes: number; color: string }[]
totalVotes: number
}
function RVoteCard({ polls, live }: { polls: PollData[]; live: boolean }) {
return (
<CardWrapper icon="🗳️" title="Group Polls" service="rVote" href="rvote.online" live={polls !== FALLBACK_POLLS}>
<div className="space-y-5">
{polls.map((poll) => (
<div key={poll.question}>
<h4 className="text-sm font-medium text-slate-200 mb-2">{poll.question}</h4>
<div className="space-y-2">
{poll.options.map((opt) => {
const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0
return (
<div key={opt.label}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-slate-300">{opt.label}</span>
<span className="text-slate-400">
{opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%)
</span>
<CardWrapper icon="🗳️" title="Group Polls" service="rVote" href="rvote.online" live={live}>
{polls.length === 0 ? (
<LoadingSkeleton />
) : (
<div className="space-y-5">
{polls.map((poll) => (
<div key={poll.question}>
<h4 className="text-sm font-medium text-slate-200 mb-2">{poll.question}</h4>
<div className="space-y-2">
{poll.options.map((opt) => {
const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0
return (
<div key={opt.label}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-slate-300">{opt.label}</span>
<span className="text-slate-400">
{opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%)
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full ${opt.color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full ${opt.color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
)
})}
</div>
<p className="text-xs text-slate-500 mt-1">{poll.totalVotes} votes cast</p>
</div>
<p className="text-xs text-slate-500 mt-1">{poll.totalVotes} votes cast</p>
</div>
))}
</div>
))}
</div>
)}
</CardWrapper>
)
}
@ -493,41 +380,47 @@ function RFundsCard({
<CardWrapper icon="💰" title="Group Expenses" service="rFunds" href="rfunds.online" live={live}>
<div className="space-y-4">
<div className="text-center py-2">
<p className="text-2xl font-bold text-white">{totalSpent.toLocaleString()}</p>
<p className="text-2xl font-bold text-white">{totalSpent > 0 ? `${totalSpent.toLocaleString()}` : '...'}</p>
<p className="text-xs text-slate-400">Total group spending</p>
</div>
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Recent</h4>
<div className="space-y-2">
{expenses.slice(0, 4).map((e) => (
<div key={e.desc} className="flex items-center justify-between text-sm">
<div className="min-w-0">
<p className="text-slate-200 truncate">{e.desc}</p>
<p className="text-xs text-slate-500">{e.who} split {e.split} ways</p>
</div>
<span className="text-slate-300 font-medium ml-2 flex-shrink-0">{e.amount}</span>
{expenses.length === 0 ? (
<LoadingSkeleton />
) : (
<>
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Recent</h4>
<div className="space-y-2">
{expenses.slice(0, 4).map((e) => (
<div key={e.desc} className="flex items-center justify-between text-sm">
<div className="min-w-0">
<p className="text-slate-200 truncate">{e.desc}</p>
<p className="text-xs text-slate-500">{e.who} split {e.split} ways</p>
</div>
<span className="text-slate-300 font-medium ml-2 flex-shrink-0">{e.amount}</span>
</div>
))}
</div>
))}
</div>
</div>
</div>
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Balances</h4>
<div className="grid grid-cols-2 gap-1.5">
{members.map((m) => {
const bal = balances[m.name] || 0
return (
<div key={m.name} className="flex items-center justify-between text-xs">
<span className="text-slate-300">{m.name}</span>
<span className={bal >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
{bal >= 0 ? '+' : ''}{Math.round(bal)}
</span>
</div>
)
})}
</div>
</div>
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Balances</h4>
<div className="grid grid-cols-2 gap-1.5">
{members.map((m) => {
const bal = balances[m.name] || 0
return (
<div key={m.name} className="flex items-center justify-between text-xs">
<span className="text-slate-300">{m.name}</span>
<span className={bal >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
{bal >= 0 ? '+' : ''}{Math.round(bal)}
</span>
</div>
)
})}
</div>
</div>
</>
)}
</div>
</CardWrapper>
)
@ -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 (
<CardWrapper icon="🛒" title="Shared Gear" service="rCart" href="rcart.online" span={2} live={live}>
<div className="space-y-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-300">{totalFunded} / {totalTarget} funded</span>
<span className="text-xs text-slate-400">
{cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden mb-4">
<div className="h-full bg-teal-500 rounded-full" style={{ width: `${totalTarget > 0 ? Math.round((totalFunded / totalTarget) * 100) : 0}%` }} />
</div>
{cartItems.length === 0 ? (
<LoadingSkeleton />
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-300">{totalFunded} / {totalTarget} funded</span>
<span className="text-xs text-slate-400">
{cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden mb-4">
<div className="h-full bg-teal-500 rounded-full" style={{ width: `${totalTarget > 0 ? Math.round((totalFunded / totalTarget) * 100) : 0}%` }} />
</div>
<div className="grid sm:grid-cols-2 gap-3">
{cartItems.map((item) => {
const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0
return (
<div key={item.item} className="bg-slate-700/30 rounded-lg p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm text-slate-200">{item.item}</span>
{item.status === 'Purchased' ? (
<span className="text-xs px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full"> Bought</span>
) : (
<span className="text-xs text-slate-400">{item.funded}/{item.target}</span>
)}
<div className="grid sm:grid-cols-2 gap-3">
{cartItems.map((item) => {
const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0
return (
<div key={item.item} className="bg-slate-700/30 rounded-lg p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm text-slate-200">{item.item}</span>
{item.status === 'Purchased' ? (
<span className="text-xs px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full"> Bought</span>
) : (
<span className="text-xs text-slate-400">{item.funded}/{item.target}</span>
)}
</div>
<div className="h-1.5 bg-slate-600 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${pct === 100 ? 'bg-emerald-500' : 'bg-teal-500'}`} style={{ width: `${pct}%` }} />
</div>
</div>
<div className="h-1.5 bg-slate-600 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${pct === 100 ? 'bg-emerald-500' : 'bg-teal-500'}`} style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
)
})}
</div>
</div>
</div>
)}
</CardWrapper>
)
}
@ -580,139 +484,140 @@ function RCartCard({ cartItems, live }: { cartItems: typeof FALLBACK_CART; live:
/* ─── Main Demo Content ─────────────────────────────────────── */
export default function DemoContent() {
const [trip, setTrip] = useState<TripData | null>(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<number, { label: string; color: string }[]>
const cal: Record<number, { label: string; color: string }[]> = {}
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<string, unknown>) => ({
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<string, unknown>) => {
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 620, 2026'
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
{/* Nav */}
@ -728,12 +633,26 @@ export default function DemoContent() {
<span className="text-slate-600">/</span>
<span className="text-sm text-slate-400">Demo</span>
</div>
<Link
href="/trips/new"
className="text-sm px-4 py-2 bg-teal-600 hover:bg-teal-500 rounded-lg transition-colors font-medium"
>
Plan Your Own Trip
</Link>
<div className="flex items-center gap-3">
{/* Connection indicator */}
<span className={`flex items-center gap-1.5 text-xs ${connected ? 'text-emerald-400' : 'text-slate-500'}`}>
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-emerald-400 animate-pulse' : 'bg-slate-600'}`} />
{connected ? 'Connected to rSpace' : 'Connecting...'}
</span>
<button
onClick={handleReset}
className="text-xs px-3 py-1.5 bg-slate-700/60 hover:bg-slate-600/60 rounded-lg text-slate-300 hover:text-white transition-colors"
title="Reset demo data to initial state"
>
Reset Demo
</button>
<Link
href="/trips/new"
className="text-sm px-4 py-2 bg-teal-600 hover:bg-teal-500 rounded-lg transition-colors font-medium"
>
Plan Your Own Trip
</Link>
</div>
</div>
</nav>
@ -741,16 +660,18 @@ export default function DemoContent() {
<section className="max-w-7xl mx-auto px-6 pt-12 pb-8">
<div className="text-center max-w-3xl mx-auto">
<h1 className="text-4xl sm:text-5xl font-bold mb-4 bg-gradient-to-r from-teal-300 via-cyan-300 to-blue-300 bg-clip-text text-transparent">
{trip?.title || 'Alpine Explorer 2026'}
{tripTitle}
</h1>
<p className="text-lg text-slate-300 mb-2">
{trip?.description || 'Chamonix → Zermatt → Dolomites'}
{destinations.length > 0
? destinations.map((d) => d.destName as string).join(' → ')
: 'Chamonix → Zermatt → Dolomites'}
</p>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
<span>📅 Jul 620, 2026</span>
<span>💶 ~{trip?.budgetTotal?.toLocaleString() || '4,500'} budget</span>
<span>🏔 3 countries</span>
{liveFlags.trip && (
<span>📅 {dateRange}</span>
<span>💶 ~{budgetTotal.toLocaleString()} budget</span>
<span>🏔 {destinations.length > 0 ? `${new Set(destinations.map((d) => d.country as string)).size} countries` : '3 countries'}</span>
{hasShapes && (
<span className="flex items-center gap-1 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Live data
@ -760,7 +681,7 @@ export default function DemoContent() {
{/* Member avatars */}
<div className="flex items-center justify-center gap-2">
{members.map((m) => (
{(members.length > 0 ? members : [{ name: 'Loading', color: 'bg-slate-600' }]).map((m) => (
<div
key={m.name}
className={`w-10 h-10 ${m.color} rounded-full flex items-center justify-center text-sm font-bold text-white ring-2 ring-slate-800`}
@ -769,7 +690,9 @@ export default function DemoContent() {
{m.name[0]}
</div>
))}
<span className="text-sm text-slate-400 ml-2">{members.length} explorers</span>
{members.length > 0 && (
<span className="text-sm text-slate-400 ml-2">{members.length} explorers</span>
)}
</div>
</div>
</section>
@ -786,12 +709,12 @@ export default function DemoContent() {
{/* Canvas Grid */}
<section className="max-w-7xl mx-auto px-6 pb-16">
<div className="grid md:grid-cols-3 gap-4">
<RMapsCard destinations={trip?.destinations} />
<RNotesCard packingItems={packingItems} tripId={trip?.id || null} onTogglePacked={handleTogglePacked} />
<RCalCard calendarEvents={calendarEvents} live={liveFlags.trip} />
<RVoteCard polls={polls} live={liveFlags.polls} />
<RFundsCard expenses={expenseData} members={members} live={liveFlags.trip} />
<RCartCard cartItems={cartData} live={liveFlags.cart} />
<RMapsCard destinations={destinations} live={hasShapes} />
<RNotesCard packingItems={packingItems} live={hasShapes} onTogglePacked={handleTogglePacked} />
<RCalCard calendarEvents={calendarEvents} live={hasShapes} />
<RVoteCard polls={polls} live={hasShapes} />
<RFundsCard expenses={expenses} members={members} live={hasShapes} />
<RCartCard cartItems={cartItems} live={hasShapes} />
</div>
</section>

221
src/lib/demo-sync.ts Normal file
View File

@ -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<string, DemoShape>;
/** Update a shape by ID (partial update merged with existing) */
updateShape: (id: string, data: Partial<DemoShape>) => void;
/** Delete a shape by ID */
deleteShape: (id: string) => void;
/** Whether WebSocket is connected */
connected: boolean;
/** Reset demo to seed state */
resetDemo: () => Promise<void>;
}
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<Record<string, DemoShape>>({});
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | 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<string, DemoShape>): Record<string, DemoShape> => {
const f = filterRef.current;
if (!f || f.length === 0) return allShapes;
const filtered: Record<string, DemoShape> = {};
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<DemoShape>) => {
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 };
}