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:
parent
6c6fb8a857
commit
fa778edc75
|
|
@ -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 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 (
|
||||
<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 6–20, 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 6–20, 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue