656 lines
30 KiB
TypeScript
656 lines
30 KiB
TypeScript
'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://rinbox.online" className="hover:text-slate-300 transition-colors">{'\u2709\uFE0F'} rInbox</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>
|
|
)
|
|
}
|