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:
parent
57f3caf8aa
commit
216df8c06a
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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