feat: add live demo page with rSpace data via useDemoSync

Replace /demo redirect with full expense tracking demo powered by
real-time WebSocket connection to the shared demo community. Budget
overview, expense editing, balance calculations, and settle-up all
sync across the r* ecosystem in real-time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:39:15 -07:00
parent 57f3caf8aa
commit 216df8c06a
3 changed files with 884 additions and 3 deletions

654
app/demo/demo-content.tsx Normal file
View File

@ -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<string, string> = {
transport: '\u{1F682}', // train
accommodation: '\u{1F3E8}', // hotel
activity: '\u{26F7}', // skier
food: '\u{1F372}', // pot of food
}
const CATEGORY_LABELS: Record<string, string> = {
transport: 'Transport',
accommodation: 'Accommodation',
activity: 'Activities',
food: 'Food & Drink',
}
const CATEGORY_COLORS: Record<string, string> = {
transport: 'bg-cyan-500',
accommodation: 'bg-violet-500',
activity: 'bg-amber-500',
food: 'bg-rose-500',
}
const CATEGORY_TEXT_COLORS: Record<string, string> = {
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<string, number> = {}
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<string | null>(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<string, number> = {}
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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
{/* Nav */}
<nav className="border-b border-slate-700/50 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-amber-400 to-emerald-500 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
rF
</div>
<span className="font-semibold text-lg">rFunds</span>
</Link>
<span className="text-slate-600">/</span>
<span className="text-sm text-slate-400">Demo</span>
</div>
<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 ? 'Live' : 'Connecting...'}
</span>
<button
onClick={handleReset}
disabled={resetting}
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 disabled:opacity-50"
>
{resetting ? 'Resetting...' : 'Reset Demo'}
</button>
<Link
href="/tbff"
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
>
Try Full App
</Link>
</div>
</div>
</nav>
{/* Hero */}
<section className="max-w-6xl 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-amber-300 via-emerald-300 to-cyan-300 bg-clip-text text-transparent">
Alpine Explorer 2026
</h1>
<p className="text-lg text-slate-300 mb-2">Group Expenses</p>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
<span>{'\u{1F4C5}'} Jul 6-20, 2026</span>
<span>{'\u{1F465}'} {MEMBERS.length} travelers</span>
<span>{'\u{1F3D4}'} Chamonix {'\u2192'} Zermatt {'\u2192'} Dolomites</span>
</div>
{/* Member avatars */}
<div className="flex items-center justify-center gap-2">
{MEMBERS.map((name, i) => {
const colors = ['bg-emerald-500', 'bg-cyan-500', 'bg-violet-500', 'bg-amber-500']
return (
<div
key={name}
className={`w-10 h-10 ${colors[i]} rounded-full flex items-center justify-center text-sm font-bold text-white ring-2 ring-slate-800`}
title={name}
>
{name[0]}
</div>
)
})}
<span className="text-sm text-slate-400 ml-2">{MEMBERS.length} members</span>
</div>
</div>
</section>
{/* Waiting for data state */}
{noData && connected && (
<section className="max-w-6xl mx-auto px-6 pb-8">
<div className="text-center py-8">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-slate-800/50 rounded-xl border border-slate-700/50 text-sm text-slate-400">
<span className="w-4 h-4 border-2 border-slate-500 border-t-emerald-400 rounded-full animate-spin" />
Waiting for demo data from rSpace...
</div>
</div>
</section>
)}
{/* Budget Overview */}
{(budget || expenses.length > 0) && (
<section className="max-w-6xl mx-auto px-6 pb-6">
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
{'\u{1F4CA}'} Trip Budget
</h2>
{budget && (
<span className="text-xs text-emerald-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
live
</span>
)}
</div>
{/* Budget totals */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-slate-700/30 rounded-xl p-4 text-center">
<p className="text-2xl font-bold text-white">{formatCurrency(budgetTotal, budget?.currency ?? 'EUR')}</p>
<p className="text-xs text-slate-400 mt-1">Total Budget</p>
</div>
<div className="bg-slate-700/30 rounded-xl p-4 text-center">
<p className="text-2xl font-bold text-emerald-400">{formatCurrency(budgetSpent, budget?.currency ?? 'EUR')}</p>
<p className="text-xs text-slate-400 mt-1">Spent</p>
</div>
<div className="bg-slate-700/30 rounded-xl p-4 text-center">
<p className={`text-2xl font-bold ${budgetRemaining >= 0 ? 'text-cyan-400' : 'text-rose-400'}`}>
{formatCurrency(Math.abs(budgetRemaining), budget?.currency ?? 'EUR')}
</p>
<p className="text-xs text-slate-400 mt-1">{budgetRemaining >= 0 ? 'Remaining' : 'Over Budget'}</p>
</div>
</div>
{/* Progress bar */}
<div className="mb-6">
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
<span>{budgetPct}% used</span>
<span>{formatCurrency(budgetRemaining, budget?.currency ?? 'EUR')} left</span>
</div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
budgetPct >= 90 ? 'bg-rose-500' : budgetPct >= 70 ? 'bg-amber-500' : 'bg-emerald-500'
}`}
style={{ width: `${budgetPct}%` }}
/>
</div>
</div>
{/* Category breakdown */}
{categoryBreakdown.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-slate-300 mb-3">Budget by Category</h3>
<div className="grid sm:grid-cols-2 gap-3">
{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 (
<div key={cat.name} className="bg-slate-700/30 rounded-xl p-3">
<div className="flex items-center justify-between mb-2">
<span className="flex items-center gap-2 text-sm text-slate-200">
<span>{CATEGORY_ICONS[catKey] || '\u{1F4B0}'}</span>
{CATEGORY_LABELS[catKey] || cat.name}
</span>
<span className="text-xs text-slate-400">
{formatCurrency(cat.spent, budget?.currency ?? 'EUR')} / {formatCurrency(cat.budget, budget?.currency ?? 'EUR')}
</span>
</div>
<div className="h-2 bg-slate-600 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
catPct >= 100 ? 'bg-rose-500' : (CATEGORY_COLORS[catKey] || 'bg-emerald-500')
}`}
style={{ width: `${catPct}%` }}
/>
</div>
<p className="text-xs text-slate-500 mt-1">{catPct}% used</p>
</div>
)
})}
</div>
</div>
)}
</div>
</section>
)}
{/* Expenses + Balances Grid */}
{expenses.length > 0 && (
<section className="max-w-6xl mx-auto px-6 pb-6">
<div className="grid md:grid-cols-3 gap-4">
{/* Expense List - spans 2 columns */}
<div className="md:col-span-2 bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-2">
<span className="text-xl">{'\u{1F4DD}'}</span>
<span className="font-semibold text-sm">Expenses ({expenses.length})</span>
</div>
<span className="text-xs text-slate-400">Click amount to edit</span>
</div>
<div className="divide-y divide-slate-700/30">
{expenses.map((expense) => {
const isEditing = editingExpenseId === expense.id
return (
<div key={expense.id} className="flex items-center gap-4 px-5 py-3 hover:bg-slate-700/20 transition-colors">
{/* Category icon */}
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-lg ${
CATEGORY_COLORS[expense.category]
? `${CATEGORY_COLORS[expense.category]}/20`
: 'bg-slate-700/40'
}`}>
{CATEGORY_ICONS[expense.category] || '\u{1F4B0}'}
</div>
{/* Description + meta */}
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-200 font-medium truncate">{expense.description}</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
<span className={CATEGORY_TEXT_COLORS[expense.category] || 'text-slate-400'}>
{CATEGORY_LABELS[expense.category] || expense.category}
</span>
<span>{'\u00B7'}</span>
<span>Paid by {expense.paidBy}</span>
<span>{'\u00B7'}</span>
<span>{expense.split === 'equal' ? `Split ${MEMBERS.length} ways` : 'Custom split'}</span>
<span>{'\u00B7'}</span>
<span>{formatDate(expense.date)}</span>
</div>
</div>
{/* Amount */}
{isEditing ? (
<div className="flex items-center gap-1.5">
<span className="text-sm text-slate-400">{expense.currency === 'CHF' ? 'CHF' : '\u20AC'}</span>
<input
type="number"
value={editAmount}
onChange={(e) => 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
/>
<button
onClick={saveEditExpense}
className="text-emerald-400 hover:text-emerald-300 text-xs px-1.5 py-0.5 bg-emerald-500/10 rounded"
>
{'\u2713'}
</button>
<button
onClick={cancelEdit}
className="text-slate-400 hover:text-slate-300 text-xs px-1.5 py-0.5 bg-slate-700/50 rounded"
>
{'\u2717'}
</button>
</div>
) : (
<button
onClick={() => startEditExpense(expense.id, expense.amount)}
className="text-right font-semibold text-slate-200 hover:text-emerald-400 transition-colors cursor-pointer group"
title="Click to edit amount"
>
<span className="text-sm">{formatCurrency(expense.amount, expense.currency)}</span>
<span className="block text-xs text-slate-500 group-hover:text-emerald-400/60 transition-colors">
{formatCurrency(expense.amount / MEMBERS.length, expense.currency)}/person
</span>
</button>
)}
</div>
)
})}
</div>
{/* Total */}
<div className="flex items-center justify-between px-5 py-3 border-t border-slate-700/50 bg-slate-700/20">
<span className="text-sm font-semibold text-slate-300">Total</span>
<span className="text-lg font-bold text-white">{formatCurrency(totalSpent, expenses[0]?.currency ?? 'EUR')}</span>
</div>
</div>
{/* Balances + Settlements */}
<div className="space-y-4">
{/* Balances card */}
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden">
<div className="px-5 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-2">
<span className="text-xl">{'\u2696\uFE0F'}</span>
<span className="font-semibold text-sm">Balances</span>
</div>
</div>
<div className="p-5 space-y-3">
{balances.map((b) => {
const colors = ['bg-emerald-500', 'bg-cyan-500', 'bg-violet-500', 'bg-amber-500']
const idx = MEMBERS.indexOf(b.name)
return (
<div key={b.name} className="flex items-center gap-3">
<div className={`w-8 h-8 ${colors[idx]} rounded-full flex items-center justify-center text-xs font-bold text-white`}>
{b.name[0]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-200">{b.name}</p>
<p className="text-xs text-slate-500">
Paid {formatCurrency(b.paid, expenses[0]?.currency ?? 'EUR')}
</p>
</div>
<span className={`text-sm font-semibold ${b.balance >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
{b.balance >= 0 ? '+' : ''}{formatCurrency(Math.round(b.balance * 100) / 100, expenses[0]?.currency ?? 'EUR')}
</span>
</div>
)
})}
</div>
</div>
{/* Settlements card */}
{settlements.length > 0 && (
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden">
<div className="px-5 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-2">
<span className="text-xl">{'\u{1F4B8}'}</span>
<span className="font-semibold text-sm">Settle Up</span>
</div>
</div>
<div className="p-5 space-y-3">
{settlements.map((s, i) => (
<div key={i} className="flex items-center gap-2 bg-slate-700/30 rounded-xl p-3">
<span className="text-sm text-rose-400 font-medium">{s.from}</span>
<span className="flex-1 text-center">
<span className="text-xs text-slate-500">{'\u2192'}</span>
<span className="block text-sm font-semibold text-white">
{formatCurrency(s.amount, expenses[0]?.currency ?? 'EUR')}
</span>
</span>
<span className="text-sm text-emerald-400 font-medium">{s.to}</span>
</div>
))}
<p className="text-xs text-slate-500 text-center mt-2">
{settlements.length} payment{settlements.length !== 1 ? 's' : ''} to settle all debts
</p>
</div>
</div>
)}
</div>
</div>
</section>
)}
{/* Expense breakdown by category */}
{expenses.length > 0 && (
<section className="max-w-6xl mx-auto px-6 pb-6">
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
{'\u{1F4CA}'} Spending by Category
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{(['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 (
<div key={cat} className="bg-slate-700/30 rounded-xl p-4 text-center">
<div className="text-2xl mb-2">{CATEGORY_ICONS[cat]}</div>
<p className="text-sm text-slate-300 font-medium">{CATEGORY_LABELS[cat]}</p>
<p className="text-lg font-bold text-white mt-1">
{formatCurrency(catTotal, expenses[0]?.currency ?? 'EUR')}
</p>
<div className="h-1.5 bg-slate-600 rounded-full overflow-hidden mt-2">
<div
className={`h-full rounded-full ${CATEGORY_COLORS[cat]}`}
style={{ width: `${catPct}%` }}
/>
</div>
<p className="text-xs text-slate-500 mt-1">{catPct}% of total</p>
</div>
)
})}
</div>
</div>
</section>
)}
{/* Per-person spending */}
{expenses.length > 0 && (
<section className="max-w-6xl mx-auto px-6 pb-6">
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
{'\u{1F464}'} Per Person
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{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 (
<div key={b.name} className="bg-slate-700/30 rounded-xl p-4 text-center">
<div className={`w-12 h-12 ${colors[i]} rounded-full flex items-center justify-center text-lg font-bold text-white mx-auto mb-2 ring-2 ring-slate-800`}>
{b.name[0]}
</div>
<p className="text-sm text-slate-300 font-medium">{b.name}</p>
<p className="text-lg font-bold text-white mt-1">
{formatCurrency(b.paid, expenses[0]?.currency ?? 'EUR')}
</p>
<p className="text-xs text-slate-500">paid ({paidPct}%)</p>
<p className={`text-sm font-semibold mt-1 ${b.balance >= 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')}
</p>
</div>
)
})}
</div>
</div>
</section>
)}
{/* CTA */}
<section className="max-w-6xl mx-auto px-6 pb-16 text-center">
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-10">
<h2 className="text-3xl font-bold mb-3">Track Your Group Expenses</h2>
<p className="text-slate-400 mb-6 max-w-lg mx-auto">
rFunds makes it easy to manage shared costs, track budgets, and settle up.
Design custom funding flows with threshold-based mechanisms.
</p>
<div className="flex items-center justify-center gap-4">
<Link
href="/tbff"
className="px-6 py-3 bg-slate-700 hover:bg-slate-600 rounded-xl text-lg font-medium transition-all border border-slate-600"
>
Try the Flow Designer
</Link>
<Link
href="/space"
className="px-8 py-3 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-emerald-900/30"
>
Create Your Space
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-700/50 py-8">
<div className="max-w-6xl mx-auto px-6">
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
<span className="font-medium text-slate-400">r* Ecosystem</span>
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">{'\u{1F30C}'} rSpace</a>
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">{'\u{1F5FA}'} rMaps</a>
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">{'\u{1F4DD}'} rNotes</a>
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">{'\u{1F5F3}'} rVote</a>
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">{'\u{1F4B0}'} rFunds</a>
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors">{'\u2708\uFE0F'} rTrips</a>
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">{'\u{1F6D2}'} rCart</a>
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">{'\u{1F4BC}'} rWallet</a>
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">{'\u{1F4C1}'} rFiles</a>
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">{'\u{1F310}'} rNetwork</a>
</div>
<p className="text-center text-xs text-slate-600">
Part of the r* ecosystem collaborative tools for communities.
</p>
</div>
</footer>
</div>
)
}

View File

@ -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 <DemoContent />
}

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

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