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'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
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 ─────────────────────────────────────────────────── */
|
function shapesByType(shapes: Record<string, DemoShape>, type: string): DemoShape[] {
|
||||||
|
return Object.values(shapes).filter((s) => s.type === type)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ItineraryItem {
|
function shapeByType(shapes: Record<string, DemoShape>, type: string): DemoShape | undefined {
|
||||||
id: string
|
return Object.values(shapes).find((s) => s.type === type)
|
||||||
title: string
|
|
||||||
date: string | null
|
|
||||||
category: string
|
|
||||||
sortOrder: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Expense {
|
/* ─── Constants ───────────────────────────────────────────────── */
|
||||||
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 ──────────────────────────────────── */
|
|
||||||
|
|
||||||
const MEMBER_COLORS = ['bg-teal-500', 'bg-cyan-500', 'bg-blue-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500']
|
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> = {
|
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',
|
FLIGHT: 'bg-teal-500',
|
||||||
TRANSPORT: 'bg-cyan-500',
|
TRANSPORT: 'bg-cyan-500',
|
||||||
ACCOMMODATION: 'bg-teal-500',
|
ACCOMMODATION: 'bg-teal-500',
|
||||||
|
|
@ -166,18 +33,26 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||||
OTHER: 'bg-slate-500',
|
OTHER: 'bg-slate-500',
|
||||||
}
|
}
|
||||||
|
|
||||||
function itineraryToCalendar(items: ItineraryItem[]): Record<number, { label: string; color: string }[]> {
|
const POLL_OPTION_COLORS = ['bg-amber-500', 'bg-blue-500', 'bg-emerald-500', 'bg-rose-500']
|
||||||
const cal: Record<number, { label: string; color: string }[]> = {}
|
|
||||||
for (const item of items) {
|
const FALLBACK_RULES = [
|
||||||
if (!item.date) continue
|
'Majority vote on daily activities',
|
||||||
const day = new Date(item.date).getUTCDate()
|
'Shared expenses split equally',
|
||||||
if (!cal[day]) cal[day] = []
|
'Quiet hours after 10pm in huts',
|
||||||
cal[day].push({
|
'Everyone carries their own pack',
|
||||||
label: item.title,
|
]
|
||||||
color: CATEGORY_COLORS[item.category] || 'bg-slate-500',
|
|
||||||
})
|
/* ─── Loading Skeleton ────────────────────────────────────────── */
|
||||||
}
|
|
||||||
return cal
|
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 ──────────────────────────────────────────── */
|
/* ─── Card Wrapper ──────────────────────────────────────────── */
|
||||||
|
|
@ -232,24 +107,23 @@ function CardWrapper({
|
||||||
|
|
||||||
/* ─── Card: rMaps ───────────────────────────────────────────── */
|
/* ─── Card: rMaps ───────────────────────────────────────────── */
|
||||||
|
|
||||||
function RMapsCard({ destinations }: { destinations?: Destination[] }) {
|
function RMapsCard({ destinations }: { destinations: DemoShape[]; live: boolean }) {
|
||||||
// Map destinations to SVG positions (evenly spaced across SVG width)
|
const pins = destinations.length > 0
|
||||||
const pins = destinations && destinations.length > 0
|
|
||||||
? destinations.map((d, i) => ({
|
? destinations.map((d, i) => ({
|
||||||
name: d.name,
|
name: d.destName as string,
|
||||||
country: d.country,
|
country: d.country as string,
|
||||||
cx: 160 + i * 245,
|
cx: 160 + i * 245,
|
||||||
cy: 180 - i * 20,
|
cy: 180 - i * 20,
|
||||||
color: ['#14b8a6', '#06b6d4', '#8b5cf6'][i] || '#94a3b8',
|
color: ['#14b8a6', '#06b6d4', '#8b5cf6'][i] || '#94a3b8',
|
||||||
stroke: ['#0d9488', '#0891b2', '#7c3aed'][i] || '#64748b',
|
stroke: ['#0d9488', '#0891b2', '#7c3aed'][i] || '#64748b',
|
||||||
dates: d.arrivalDate && d.departureDate
|
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: '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 12–16' },
|
{ 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 17–20' },
|
{ name: 'Dolomites', country: 'Italy', cx: 650, cy: 140, color: '#8b5cf6', stroke: '#7c3aed', dates: 'Jul 14–20' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const routePath = pins.length >= 3
|
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'
|
: 'M160 180 C250 160, 350 200, 430 150 C510 100, 560 160, 650 140'
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<svg viewBox="0 0 800 300" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||||
{/* Mountain silhouettes */}
|
{/* Mountain silhouettes */}
|
||||||
|
|
@ -311,43 +185,47 @@ function RMapsCard({ destinations }: { destinations?: Destination[] }) {
|
||||||
|
|
||||||
function RNotesCard({
|
function RNotesCard({
|
||||||
packingItems,
|
packingItems,
|
||||||
tripId,
|
live,
|
||||||
onTogglePacked,
|
onTogglePacked,
|
||||||
}: {
|
}: {
|
||||||
packingItems: { id: string; name: string; packed: boolean }[]
|
packingItems: { name: string; packed: boolean; category: string }[]
|
||||||
tripId: string | null
|
live: boolean
|
||||||
onTogglePacked: (itemId: string) => void
|
onTogglePacked: (index: number) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
|
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
|
||||||
Packing Checklist
|
Packing Checklist
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-1.5">
|
{packingItems.length === 0 ? (
|
||||||
{packingItems.map((item) => (
|
<LoadingSkeleton />
|
||||||
<li key={item.id} className="flex items-center gap-2 text-sm">
|
) : (
|
||||||
<button
|
<ul className="space-y-1.5">
|
||||||
onClick={() => onTogglePacked(item.id)}
|
{packingItems.map((item, idx) => (
|
||||||
className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors cursor-pointer ${
|
<li key={`${item.name}-${idx}`} className="flex items-center gap-2 text-sm">
|
||||||
item.packed
|
<button
|
||||||
? 'bg-teal-500/20 border-teal-500 text-teal-400'
|
onClick={() => onTogglePacked(idx)}
|
||||||
: 'border-slate-600 hover:border-slate-400'
|
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'
|
||||||
{item.packed && (
|
: 'border-slate-600 hover:border-slate-400'
|
||||||
<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>
|
{item.packed && (
|
||||||
)}
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
<span className={item.packed ? 'text-slate-400 line-through' : 'text-slate-200'}>
|
</svg>
|
||||||
{item.name}
|
)}
|
||||||
</span>
|
</button>
|
||||||
</li>
|
<span className={item.packed ? 'text-slate-400 line-through' : 'text-slate-200'}>
|
||||||
))}
|
{item.name}
|
||||||
</ul>
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -383,7 +261,7 @@ function RCalCard({ calendarEvents, live }: { calendarEvents: Record<number, { l
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h4 className="text-sm font-semibold text-slate-200">July 2026</h4>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
<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-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-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-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-violet-500" /> Culture</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-slate-500" /> Rest</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-cyan-500" /> Transit</span>
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-cyan-500" /> Transit</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -433,35 +310,45 @@ function RCalCard({ calendarEvents, live }: { calendarEvents: Record<number, { l
|
||||||
|
|
||||||
/* ─── Card: rVote ───────────────────────────────────────────── */
|
/* ─── 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 (
|
return (
|
||||||
<CardWrapper icon="🗳️" title="Group Polls" service="rVote" href="rvote.online" live={polls !== FALLBACK_POLLS}>
|
<CardWrapper icon="🗳️" title="Group Polls" service="rVote" href="rvote.online" live={live}>
|
||||||
<div className="space-y-5">
|
{polls.length === 0 ? (
|
||||||
{polls.map((poll) => (
|
<LoadingSkeleton />
|
||||||
<div key={poll.question}>
|
) : (
|
||||||
<h4 className="text-sm font-medium text-slate-200 mb-2">{poll.question}</h4>
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
{polls.map((poll) => (
|
||||||
{poll.options.map((opt) => {
|
<div key={poll.question}>
|
||||||
const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0
|
<h4 className="text-sm font-medium text-slate-200 mb-2">{poll.question}</h4>
|
||||||
return (
|
<div className="space-y-2">
|
||||||
<div key={opt.label}>
|
{poll.options.map((opt) => {
|
||||||
<div className="flex items-center justify-between text-xs mb-1">
|
const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0
|
||||||
<span className="text-slate-300">{opt.label}</span>
|
return (
|
||||||
<span className="text-slate-400">
|
<div key={opt.label}>
|
||||||
{opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%)
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
</span>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<p className="text-xs text-slate-500 mt-1">{poll.totalVotes} votes cast</p>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
</CardWrapper>
|
</CardWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -493,41 +380,47 @@ function RFundsCard({
|
||||||
<CardWrapper icon="💰" title="Group Expenses" service="rFunds" href="rfunds.online" live={live}>
|
<CardWrapper icon="💰" title="Group Expenses" service="rFunds" href="rfunds.online" live={live}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-center py-2">
|
<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>
|
<p className="text-xs text-slate-400">Total group spending</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{expenses.length === 0 ? (
|
||||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Recent</h4>
|
<LoadingSkeleton />
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
{expenses.slice(0, 4).map((e) => (
|
<>
|
||||||
<div key={e.desc} className="flex items-center justify-between text-sm">
|
<div>
|
||||||
<div className="min-w-0">
|
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Recent</h4>
|
||||||
<p className="text-slate-200 truncate">{e.desc}</p>
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-slate-500">{e.who} • split {e.split} ways</p>
|
{expenses.slice(0, 4).map((e) => (
|
||||||
</div>
|
<div key={e.desc} className="flex items-center justify-between text-sm">
|
||||||
<span className="text-slate-300 font-medium ml-2 flex-shrink-0">€{e.amount}</span>
|
<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>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Balances</h4>
|
<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">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{members.map((m) => {
|
{members.map((m) => {
|
||||||
const bal = balances[m.name] || 0
|
const bal = balances[m.name] || 0
|
||||||
return (
|
return (
|
||||||
<div key={m.name} className="flex items-center justify-between text-xs">
|
<div key={m.name} className="flex items-center justify-between text-xs">
|
||||||
<span className="text-slate-300">{m.name}</span>
|
<span className="text-slate-300">{m.name}</span>
|
||||||
<span className={bal >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
<span className={bal >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
||||||
{bal >= 0 ? '+' : ''}€{Math.round(bal)}
|
{bal >= 0 ? '+' : ''}€{Math.round(bal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardWrapper>
|
</CardWrapper>
|
||||||
)
|
)
|
||||||
|
|
@ -535,44 +428,55 @@ function RFundsCard({
|
||||||
|
|
||||||
/* ─── Card: rCart ────────────────────────────────────────────── */
|
/* ─── 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 totalFunded = cartItems.reduce((s, i) => s + i.funded, 0)
|
||||||
const totalTarget = cartItems.reduce((s, i) => s + i.target, 0)
|
const totalTarget = cartItems.reduce((s, i) => s + i.target, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardWrapper icon="🛒" title="Shared Gear" service="rCart" href="rcart.online" span={2} live={live}>
|
<CardWrapper icon="🛒" title="Shared Gear" service="rCart" href="rcart.online" span={2} live={live}>
|
||||||
<div className="space-y-3">
|
{cartItems.length === 0 ? (
|
||||||
<div className="flex items-center justify-between text-sm mb-1">
|
<LoadingSkeleton />
|
||||||
<span className="text-slate-300">€{totalFunded} / €{totalTarget} funded</span>
|
) : (
|
||||||
<span className="text-xs text-slate-400">
|
<div className="space-y-3">
|
||||||
{cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
</span>
|
<span className="text-slate-300">€{totalFunded} / €{totalTarget} funded</span>
|
||||||
</div>
|
<span className="text-xs text-slate-400">
|
||||||
<div className="h-2 bg-slate-700 rounded-full overflow-hidden mb-4">
|
{cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased
|
||||||
<div className="h-full bg-teal-500 rounded-full" style={{ width: `${totalTarget > 0 ? Math.round((totalFunded / totalTarget) * 100) : 0}%` }} />
|
</span>
|
||||||
</div>
|
</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">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
{cartItems.map((item) => {
|
{cartItems.map((item) => {
|
||||||
const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0
|
const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0
|
||||||
return (
|
return (
|
||||||
<div key={item.item} className="bg-slate-700/30 rounded-lg p-3">
|
<div key={item.item} className="bg-slate-700/30 rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-sm text-slate-200">{item.item}</span>
|
<span className="text-sm text-slate-200">{item.item}</span>
|
||||||
{item.status === 'Purchased' ? (
|
{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 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>
|
<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>
|
||||||
<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>
|
||||||
</div>
|
)}
|
||||||
</CardWrapper>
|
</CardWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -580,139 +484,140 @@ function RCartCard({ cartItems, live }: { cartItems: typeof FALLBACK_CART; live:
|
||||||
/* ─── Main Demo Content ─────────────────────────────────────── */
|
/* ─── Main Demo Content ─────────────────────────────────────── */
|
||||||
|
|
||||||
export default function DemoContent() {
|
export default function DemoContent() {
|
||||||
const [trip, setTrip] = useState<TripData | null>(null)
|
const { shapes, updateShape, connected, resetDemo } = useDemoSync()
|
||||||
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 })
|
|
||||||
|
|
||||||
// Fetch trip data (native rTrips)
|
const hasShapes = Object.keys(shapes).length > 0
|
||||||
useEffect(() => {
|
|
||||||
fetch(`/api/trips/by-slug/${DEMO_SLUG}`)
|
// ── Extract shapes by type ──────────────────────────────────
|
||||||
.then((r) => {
|
|
||||||
if (!r.ok) throw new Error(`${r.status}`)
|
const itinerary = useMemo(() => shapeByType(shapes, 'folk-itinerary'), [shapes])
|
||||||
return r.json()
|
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
|
// ── Derived data: packing items ────────────────────────────
|
||||||
if (data.packingItems?.length > 0) {
|
|
||||||
setPackingItems(data.packingItems.map((p) => ({ id: p.id, name: p.name, packed: p.packed })))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calendar from itinerary
|
const packingItems = useMemo(() => {
|
||||||
if (data.itineraryItems?.length > 0) {
|
const items = (packingList?.items ?? []) as { name: string; packed: boolean; category: string }[]
|
||||||
setCalendarEvents(itineraryToCalendar(data.itineraryItems))
|
return items
|
||||||
}
|
}, [packingList])
|
||||||
|
|
||||||
// Expenses
|
// ── Derived data: polls ────────────────────────────────────
|
||||||
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
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Members from collaborators
|
const polls = useMemo((): PollData[] => {
|
||||||
if (data.collaborators?.length > 0) {
|
return pollShapes.map((shape) => {
|
||||||
setMembers(
|
const options = (shape.options ?? []) as { label: string; votes: number }[]
|
||||||
data.collaborators.map((c, i) => ({
|
const totalVotes = options.reduce((sum, o) => sum + o.votes, 0)
|
||||||
name: c.user.username || `User ${i + 1}`,
|
return {
|
||||||
color: MEMBER_COLORS[i % MEMBER_COLORS.length],
|
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],
|
||||||
setLiveFlags((f) => ({ ...f, trip: true }))
|
})),
|
||||||
})
|
totalVotes,
|
||||||
.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)))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}, [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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
|
|
@ -728,12 +633,26 @@ export default function DemoContent() {
|
||||||
<span className="text-slate-600">/</span>
|
<span className="text-slate-600">/</span>
|
||||||
<span className="text-sm text-slate-400">Demo</span>
|
<span className="text-sm text-slate-400">Demo</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex items-center gap-3">
|
||||||
href="/trips/new"
|
{/* Connection indicator */}
|
||||||
className="text-sm px-4 py-2 bg-teal-600 hover:bg-teal-500 rounded-lg transition-colors font-medium"
|
<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'}`} />
|
||||||
Plan Your Own Trip
|
{connected ? 'Connected to rSpace' : 'Connecting...'}
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -741,16 +660,18 @@ export default function DemoContent() {
|
||||||
<section className="max-w-7xl mx-auto px-6 pt-12 pb-8">
|
<section className="max-w-7xl mx-auto px-6 pt-12 pb-8">
|
||||||
<div className="text-center max-w-3xl mx-auto">
|
<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">
|
<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>
|
</h1>
|
||||||
<p className="text-lg text-slate-300 mb-2">
|
<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>
|
</p>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
|
<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>📅 {dateRange}</span>
|
||||||
<span>💶 ~€{trip?.budgetTotal?.toLocaleString() || '4,500'} budget</span>
|
<span>💶 ~€{budgetTotal.toLocaleString()} budget</span>
|
||||||
<span>🏔️ 3 countries</span>
|
<span>🏔️ {destinations.length > 0 ? `${new Set(destinations.map((d) => d.country as string)).size} countries` : '3 countries'}</span>
|
||||||
{liveFlags.trip && (
|
{hasShapes && (
|
||||||
<span className="flex items-center gap-1 text-emerald-400">
|
<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" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
Live data
|
Live data
|
||||||
|
|
@ -760,7 +681,7 @@ export default function DemoContent() {
|
||||||
|
|
||||||
{/* Member avatars */}
|
{/* Member avatars */}
|
||||||
<div className="flex items-center justify-center gap-2">
|
<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
|
<div
|
||||||
key={m.name}
|
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`}
|
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]}
|
{m.name[0]}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -786,12 +709,12 @@ export default function DemoContent() {
|
||||||
{/* Canvas Grid */}
|
{/* Canvas Grid */}
|
||||||
<section className="max-w-7xl mx-auto px-6 pb-16">
|
<section className="max-w-7xl mx-auto px-6 pb-16">
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
<RMapsCard destinations={trip?.destinations} />
|
<RMapsCard destinations={destinations} live={hasShapes} />
|
||||||
<RNotesCard packingItems={packingItems} tripId={trip?.id || null} onTogglePacked={handleTogglePacked} />
|
<RNotesCard packingItems={packingItems} live={hasShapes} onTogglePacked={handleTogglePacked} />
|
||||||
<RCalCard calendarEvents={calendarEvents} live={liveFlags.trip} />
|
<RCalCard calendarEvents={calendarEvents} live={hasShapes} />
|
||||||
<RVoteCard polls={polls} live={liveFlags.polls} />
|
<RVoteCard polls={polls} live={hasShapes} />
|
||||||
<RFundsCard expenses={expenseData} members={members} live={liveFlags.trip} />
|
<RFundsCard expenses={expenses} members={members} live={hasShapes} />
|
||||||
<RCartCard cartItems={cartData} live={liveFlags.cart} />
|
<RCartCard cartItems={cartItems} live={hasShapes} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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