diff --git a/app/demo/demo-content.tsx b/app/demo/demo-content.tsx new file mode 100644 index 0000000..37a135c --- /dev/null +++ b/app/demo/demo-content.tsx @@ -0,0 +1,654 @@ +'use client' + +import Link from 'next/link' +import { useState, useMemo, useCallback } from 'react' +import { useDemoSync, type DemoShape } from '@/lib/demo-sync' + +/* ─── Types ─────────────────────────────────────────────────── */ + +interface ExpenseShape extends DemoShape { + type: 'demo-expense' + description: string + amount: number + currency: string + paidBy: string + split: 'equal' | 'custom' + category: 'transport' | 'accommodation' | 'activity' | 'food' + date: string +} + +interface BudgetShape extends DemoShape { + type: 'folk-budget' + budgetTitle: string + currency: string + budgetTotal: number + spent: number + categories: { name: string; budget: number; spent: number }[] +} + +/* ─── Constants ─────────────────────────────────────────────── */ + +const MEMBERS = ['Maya', 'Liam', 'Priya', 'Omar'] + +const CATEGORY_ICONS: Record = { + transport: '\u{1F682}', // train + accommodation: '\u{1F3E8}', // hotel + activity: '\u{26F7}', // skier + food: '\u{1F372}', // pot of food +} + +const CATEGORY_LABELS: Record = { + transport: 'Transport', + accommodation: 'Accommodation', + activity: 'Activities', + food: 'Food & Drink', +} + +const CATEGORY_COLORS: Record = { + transport: 'bg-cyan-500', + accommodation: 'bg-violet-500', + activity: 'bg-amber-500', + food: 'bg-rose-500', +} + +const CATEGORY_TEXT_COLORS: Record = { + transport: 'text-cyan-400', + accommodation: 'text-violet-400', + activity: 'text-amber-400', + food: 'text-rose-400', +} + +/* ─── Helpers ───────────────────────────────────────────────── */ + +function formatCurrency(amount: number, currency: string): string { + const symbol = currency === 'CHF' ? 'CHF ' : '\u20AC' + if (currency === 'CHF') return `${symbol}${amount.toLocaleString()}` + return `${symbol}${amount.toLocaleString()}` +} + +function formatDate(dateStr: string): string { + try { + const d = new Date(dateStr) + return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + } catch { + return dateStr + } +} + +/* ─── Compute Balances ──────────────────────────────────────── */ + +function computeBalances(expenses: ExpenseShape[]): { name: string; paid: number; owes: number; balance: number }[] { + const totalExpenses = expenses.reduce((sum, e) => sum + e.amount, 0) + const perPerson = totalExpenses / MEMBERS.length + + const paid: Record = {} + MEMBERS.forEach((m) => (paid[m] = 0)) + expenses.forEach((e) => { + paid[e.paidBy] = (paid[e.paidBy] || 0) + e.amount + }) + + return MEMBERS.map((name) => ({ + name, + paid: paid[name] || 0, + owes: perPerson, + balance: (paid[name] || 0) - perPerson, + })) +} + +function computeSettlements(balances: { name: string; balance: number }[]): { from: string; to: string; amount: number }[] { + const debtors = balances.filter((b) => b.balance < -0.01).map((b) => ({ ...b, balance: -b.balance })) + const creditors = balances.filter((b) => b.balance > 0.01).map((b) => ({ ...b })) + + debtors.sort((a, b) => b.balance - a.balance) + creditors.sort((a, b) => b.balance - a.balance) + + const settlements: { from: string; to: string; amount: number }[] = [] + let di = 0, ci = 0 + + while (di < debtors.length && ci < creditors.length) { + const amount = Math.min(debtors[di].balance, creditors[ci].balance) + if (amount > 0.01) { + settlements.push({ from: debtors[di].name, to: creditors[ci].name, amount: Math.round(amount * 100) / 100 }) + } + debtors[di].balance -= amount + creditors[ci].balance -= amount + if (debtors[di].balance < 0.01) di++ + if (creditors[ci].balance < 0.01) ci++ + } + + return settlements +} + +/* ─── Main Component ────────────────────────────────────────── */ + +export default function DemoContent() { + const { shapes, updateShape, connected, resetDemo } = useDemoSync({ + filter: ['demo-expense', 'folk-budget'], + }) + + const [editingExpenseId, setEditingExpenseId] = useState(null) + const [editAmount, setEditAmount] = useState('') + const [resetting, setResetting] = useState(false) + + // Extract typed shapes + const expenses = useMemo(() => { + return Object.values(shapes) + .filter((s): s is ExpenseShape => s.type === 'demo-expense') + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + }, [shapes]) + + const budget = useMemo(() => { + return Object.values(shapes).find((s): s is BudgetShape => s.type === 'folk-budget') ?? null + }, [shapes]) + + // Computed values + const totalSpent = useMemo(() => expenses.reduce((s, e) => s + e.amount, 0), [expenses]) + const balances = useMemo(() => computeBalances(expenses), [expenses]) + const settlements = useMemo(() => computeSettlements(balances), [balances]) + + const budgetTotal = budget?.budgetTotal ?? 4000 + const budgetSpent = budget?.spent ?? totalSpent + const budgetRemaining = budgetTotal - budgetSpent + const budgetPct = budgetTotal > 0 ? Math.min(100, Math.round((budgetSpent / budgetTotal) * 100)) : 0 + + // Category breakdown from budget shape or computed from expenses + const categoryBreakdown = useMemo(() => { + if (budget?.categories && budget.categories.length > 0) { + return budget.categories + } + // Fallback: compute from expenses + const cats: Record = {} + expenses.forEach((e) => { + cats[e.category] = (cats[e.category] || 0) + e.amount + }) + return Object.entries(cats).map(([name, spent]) => ({ + name, + budget: Math.round(budgetTotal / 4), + spent, + })) + }, [budget, expenses, budgetTotal]) + + // Expense amount editing + const startEditExpense = useCallback((id: string, currentAmount: number) => { + setEditingExpenseId(id) + setEditAmount(String(currentAmount)) + }, []) + + const saveEditExpense = useCallback(() => { + if (!editingExpenseId) return + const newAmount = parseFloat(editAmount) + if (isNaN(newAmount) || newAmount < 0) { + setEditingExpenseId(null) + return + } + updateShape(editingExpenseId, { amount: Math.round(newAmount * 100) / 100 }) + setEditingExpenseId(null) + setEditAmount('') + }, [editingExpenseId, editAmount, updateShape]) + + const cancelEdit = useCallback(() => { + setEditingExpenseId(null) + setEditAmount('') + }, []) + + const handleReset = useCallback(async () => { + setResetting(true) + try { + await resetDemo() + } catch { + // silent + } finally { + setTimeout(() => setResetting(false), 1000) + } + }, [resetDemo]) + + const noData = expenses.length === 0 && !budget + + return ( +
+ {/* Nav */} + + + {/* Hero */} +
+
+

+ Alpine Explorer 2026 +

+

Group Expenses

+
+ {'\u{1F4C5}'} Jul 6-20, 2026 + {'\u{1F465}'} {MEMBERS.length} travelers + {'\u{1F3D4}'} Chamonix {'\u2192'} Zermatt {'\u2192'} Dolomites +
+ + {/* Member avatars */} +
+ {MEMBERS.map((name, i) => { + const colors = ['bg-emerald-500', 'bg-cyan-500', 'bg-violet-500', 'bg-amber-500'] + return ( +
+ {name[0]} +
+ ) + })} + {MEMBERS.length} members +
+
+
+ + {/* Waiting for data state */} + {noData && connected && ( +
+
+
+ + Waiting for demo data from rSpace... +
+
+
+ )} + + {/* Budget Overview */} + {(budget || expenses.length > 0) && ( +
+
+
+

+ {'\u{1F4CA}'} Trip Budget +

+ {budget && ( + + + live + + )} +
+ + {/* Budget totals */} +
+
+

{formatCurrency(budgetTotal, budget?.currency ?? 'EUR')}

+

Total Budget

+
+
+

{formatCurrency(budgetSpent, budget?.currency ?? 'EUR')}

+

Spent

+
+
+

= 0 ? 'text-cyan-400' : 'text-rose-400'}`}> + {formatCurrency(Math.abs(budgetRemaining), budget?.currency ?? 'EUR')} +

+

{budgetRemaining >= 0 ? 'Remaining' : 'Over Budget'}

+
+
+ + {/* Progress bar */} +
+
+ {budgetPct}% used + {formatCurrency(budgetRemaining, budget?.currency ?? 'EUR')} left +
+
+
= 90 ? 'bg-rose-500' : budgetPct >= 70 ? 'bg-amber-500' : 'bg-emerald-500' + }`} + style={{ width: `${budgetPct}%` }} + /> +
+
+ + {/* Category breakdown */} + {categoryBreakdown.length > 0 && ( +
+

Budget by Category

+
+ {categoryBreakdown.map((cat) => { + const catPct = cat.budget > 0 ? Math.min(100, Math.round((cat.spent / cat.budget) * 100)) : 0 + const catKey = cat.name.toLowerCase() + return ( +
+
+ + {CATEGORY_ICONS[catKey] || '\u{1F4B0}'} + {CATEGORY_LABELS[catKey] || cat.name} + + + {formatCurrency(cat.spent, budget?.currency ?? 'EUR')} / {formatCurrency(cat.budget, budget?.currency ?? 'EUR')} + +
+
+
= 100 ? 'bg-rose-500' : (CATEGORY_COLORS[catKey] || 'bg-emerald-500') + }`} + style={{ width: `${catPct}%` }} + /> +
+

{catPct}% used

+
+ ) + })} +
+
+ )} +
+
+ )} + + {/* Expenses + Balances Grid */} + {expenses.length > 0 && ( +
+
+ {/* Expense List - spans 2 columns */} +
+
+
+ {'\u{1F4DD}'} + Expenses ({expenses.length}) +
+ Click amount to edit +
+
+ {expenses.map((expense) => { + const isEditing = editingExpenseId === expense.id + return ( +
+ {/* Category icon */} +
+ {CATEGORY_ICONS[expense.category] || '\u{1F4B0}'} +
+ + {/* Description + meta */} +
+

{expense.description}

+
+ + {CATEGORY_LABELS[expense.category] || expense.category} + + {'\u00B7'} + Paid by {expense.paidBy} + {'\u00B7'} + {expense.split === 'equal' ? `Split ${MEMBERS.length} ways` : 'Custom split'} + {'\u00B7'} + {formatDate(expense.date)} +
+
+ + {/* Amount */} + {isEditing ? ( +
+ {expense.currency === 'CHF' ? 'CHF' : '\u20AC'} + setEditAmount(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') saveEditExpense() + if (e.key === 'Escape') cancelEdit() + }} + className="w-20 bg-slate-700 border border-emerald-500/50 rounded-lg px-2 py-1 text-sm text-white text-right focus:outline-none focus:border-emerald-400" + autoFocus + /> + + +
+ ) : ( + + )} +
+ ) + })} +
+ + {/* Total */} +
+ Total + {formatCurrency(totalSpent, expenses[0]?.currency ?? 'EUR')} +
+
+ + {/* Balances + Settlements */} +
+ {/* Balances card */} +
+
+
+ {'\u2696\uFE0F'} + Balances +
+
+
+ {balances.map((b) => { + const colors = ['bg-emerald-500', 'bg-cyan-500', 'bg-violet-500', 'bg-amber-500'] + const idx = MEMBERS.indexOf(b.name) + return ( +
+
+ {b.name[0]} +
+
+

{b.name}

+

+ Paid {formatCurrency(b.paid, expenses[0]?.currency ?? 'EUR')} +

+
+ = 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + {b.balance >= 0 ? '+' : ''}{formatCurrency(Math.round(b.balance * 100) / 100, expenses[0]?.currency ?? 'EUR')} + +
+ ) + })} +
+
+ + {/* Settlements card */} + {settlements.length > 0 && ( +
+
+
+ {'\u{1F4B8}'} + Settle Up +
+
+
+ {settlements.map((s, i) => ( +
+ {s.from} + + {'\u2192'} + + {formatCurrency(s.amount, expenses[0]?.currency ?? 'EUR')} + + + {s.to} +
+ ))} +

+ {settlements.length} payment{settlements.length !== 1 ? 's' : ''} to settle all debts +

+
+
+ )} +
+
+
+ )} + + {/* Expense breakdown by category */} + {expenses.length > 0 && ( +
+
+

+ {'\u{1F4CA}'} Spending by Category +

+
+ {(['transport', 'accommodation', 'activity', 'food'] as const).map((cat) => { + const catExpenses = expenses.filter((e) => e.category === cat) + const catTotal = catExpenses.reduce((s, e) => s + e.amount, 0) + const catPct = totalSpent > 0 ? Math.round((catTotal / totalSpent) * 100) : 0 + return ( +
+
{CATEGORY_ICONS[cat]}
+

{CATEGORY_LABELS[cat]}

+

+ {formatCurrency(catTotal, expenses[0]?.currency ?? 'EUR')} +

+
+
+
+

{catPct}% of total

+
+ ) + })} +
+
+
+ )} + + {/* Per-person spending */} + {expenses.length > 0 && ( +
+
+

+ {'\u{1F464}'} Per Person +

+
+ {balances.map((b, i) => { + const colors = ['bg-emerald-500', 'bg-cyan-500', 'bg-violet-500', 'bg-amber-500'] + const paidPct = totalSpent > 0 ? Math.round((b.paid / totalSpent) * 100) : 0 + return ( +
+
+ {b.name[0]} +
+

{b.name}

+

+ {formatCurrency(b.paid, expenses[0]?.currency ?? 'EUR')} +

+

paid ({paidPct}%)

+

= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + {b.balance >= 0 ? 'Gets back ' : 'Owes '} + {formatCurrency(Math.abs(Math.round(b.balance * 100) / 100), expenses[0]?.currency ?? 'EUR')} +

+
+ ) + })} +
+
+
+ )} + + {/* CTA */} +
+
+

Track Your Group Expenses

+

+ rFunds makes it easy to manage shared costs, track budgets, and settle up. + Design custom funding flows with threshold-based mechanisms. +

+
+ + Try the Flow Designer + + + Create Your Space + +
+
+
+ + {/* Footer */} + +
+ ) +} diff --git a/app/demo/page.tsx b/app/demo/page.tsx index e128ca0..5dc57f5 100644 --- a/app/demo/page.tsx +++ b/app/demo/page.tsx @@ -1,5 +1,11 @@ -import { redirect } from 'next/navigation' +import type { Metadata } from 'next' +import DemoContent from './demo-content' -export default function DemoRedirect() { - redirect('/tbff') +export const metadata: Metadata = { + title: 'rFunds Demo - Alpine Explorer 2026 Group Expenses', + description: 'See how rFunds handles group expense tracking, budget management, and balance settlement. Live demo powered by rSpace real-time sync.', +} + +export default function DemoPage() { + return } diff --git a/lib/demo-sync.ts b/lib/demo-sync.ts new file mode 100644 index 0000000..6487586 --- /dev/null +++ b/lib/demo-sync.ts @@ -0,0 +1,221 @@ +/** + * useDemoSync — lightweight React hook for real-time demo data via rSpace + * + * Connects to rSpace WebSocket in JSON mode (no Automerge bundle needed). + * All demo pages share the "demo" community, so changes in one app + * propagate to every other app viewing the same shapes. + * + * Usage: + * const { shapes, updateShape, deleteShape, connected, resetDemo } = useDemoSync({ + * filter: ['folk-note', 'folk-notebook'], // optional: only these shape types + * }); + */ + +import { useEffect, useRef, useState, useCallback } from 'react'; + +export interface DemoShape { + type: string; + id: string; + x: number; + y: number; + width: number; + height: number; + rotation: number; + [key: string]: unknown; +} + +interface UseDemoSyncOptions { + /** Community slug (default: 'demo') */ + slug?: string; + /** Only subscribe to these shape types */ + filter?: string[]; + /** rSpace server URL (default: auto-detect based on environment) */ + serverUrl?: string; +} + +interface UseDemoSyncReturn { + /** Current shapes (filtered if filter option set) */ + shapes: Record; + /** Update a shape by ID (partial update merged with existing) */ + updateShape: (id: string, data: Partial) => void; + /** Delete a shape by ID */ + deleteShape: (id: string) => void; + /** Whether WebSocket is connected */ + connected: boolean; + /** Reset demo to seed state */ + resetDemo: () => Promise; +} + +const DEFAULT_SLUG = 'demo'; +const RECONNECT_BASE_MS = 1000; +const RECONNECT_MAX_MS = 30000; +const PING_INTERVAL_MS = 30000; + +function getDefaultServerUrl(): string { + if (typeof window === 'undefined') return 'https://rspace.online'; + // In development, use localhost + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + return `http://${window.location.hostname}:3000`; + } + return 'https://rspace.online'; +} + +export function useDemoSync(options?: UseDemoSyncOptions): UseDemoSyncReturn { + const slug = options?.slug ?? DEFAULT_SLUG; + const filter = options?.filter; + const serverUrl = options?.serverUrl ?? getDefaultServerUrl(); + + const [shapes, setShapes] = useState>({}); + const [connected, setConnected] = useState(false); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const pingTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + // Stable filter reference for use in callbacks + const filterRef = useRef(filter); + filterRef.current = filter; + + const applyFilter = useCallback((allShapes: Record): Record => { + const f = filterRef.current; + if (!f || f.length === 0) return allShapes; + const filtered: Record = {}; + for (const [id, shape] of Object.entries(allShapes)) { + if (f.includes(shape.type)) { + filtered[id] = shape; + } + } + return filtered; + }, []); + + const connect = useCallback(() => { + if (!mountedRef.current) return; + + // Build WebSocket URL + const wsProtocol = serverUrl.startsWith('https') ? 'wss' : 'ws'; + const host = serverUrl.replace(/^https?:\/\//, ''); + const wsUrl = `${wsProtocol}://${host}/ws/${slug}?mode=json`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (!mountedRef.current) return; + setConnected(true); + reconnectAttemptRef.current = 0; + + // Start ping keepalive + pingTimerRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } + }, PING_INTERVAL_MS); + }; + + ws.onmessage = (event) => { + if (!mountedRef.current) return; + try { + const msg = JSON.parse(event.data); + if (msg.type === 'snapshot' && msg.shapes) { + setShapes(applyFilter(msg.shapes)); + } + // pong and error messages are silently handled + } catch { + // ignore parse errors + } + }; + + ws.onclose = () => { + if (!mountedRef.current) return; + setConnected(false); + cleanup(); + scheduleReconnect(); + }; + + ws.onerror = () => { + // onclose will fire after onerror, so reconnect is handled there + }; + }, [slug, serverUrl, applyFilter]); + + const cleanup = useCallback(() => { + if (pingTimerRef.current) { + clearInterval(pingTimerRef.current); + pingTimerRef.current = null; + } + }, []); + + const scheduleReconnect = useCallback(() => { + if (!mountedRef.current) return; + const attempt = reconnectAttemptRef.current; + const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS); + reconnectAttemptRef.current = attempt + 1; + + reconnectTimerRef.current = setTimeout(() => { + if (mountedRef.current) connect(); + }, delay); + }, [connect]); + + // Connect on mount + useEffect(() => { + mountedRef.current = true; + connect(); + + return () => { + mountedRef.current = false; + cleanup(); + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + if (wsRef.current) { + wsRef.current.onclose = null; // prevent reconnect on unmount + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [connect, cleanup]); + + const updateShape = useCallback((id: string, data: Partial) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // Optimistic local update + setShapes((prev) => { + const existing = prev[id]; + if (!existing) return prev; + const updated = { ...existing, ...data, id }; + const f = filterRef.current; + if (f && f.length > 0 && !f.includes(updated.type)) return prev; + return { ...prev, [id]: updated }; + }); + + // Send to server + ws.send(JSON.stringify({ type: 'update', id, data: { ...data, id } })); + }, []); + + const deleteShape = useCallback((id: string) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // Optimistic local delete + setShapes((prev) => { + const { [id]: _, ...rest } = prev; + return rest; + }); + + ws.send(JSON.stringify({ type: 'delete', id })); + }, []); + + const resetDemo = useCallback(async () => { + const res = await fetch(`${serverUrl}/api/communities/demo/reset`, { method: 'POST' }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Reset failed: ${res.status} ${body}`); + } + // The server will broadcast new snapshot via WebSocket + }, [serverUrl]); + + return { shapes, updateShape, deleteShape, connected, resetDemo }; +}