Add interactive demo with cross-service data via proxy routes

- Convert demo page from static server component to interactive client
  component with live data fetching and graceful static fallbacks
- Add /api/trips/by-slug/[slug] for slug-based trip lookup
- Add /api/trips/[id]/packing/[itemId] PATCH for toggling packed status
- Add proxy routes for rNotes, rVote, rCart (server-side, no CORS needed)
- Add demo trip seed script (prisma/seed-demo.ts)
- Packing checkboxes are now interactive with optimistic updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:05:35 -07:00
parent 1a7e754425
commit 0d3d636751
10 changed files with 1226 additions and 625 deletions

View File

@ -9,6 +9,9 @@ services:
- NEXT_PUBLIC_RSPACE_URL=${NEXT_PUBLIC_RSPACE_URL:-https://rspace.online}
- RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000}
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com}
- RNOTES_INTERNAL_URL=${RNOTES_INTERNAL_URL:-http://rnotes-online:3000}
- RVOTE_INTERNAL_URL=${RVOTE_INTERNAL_URL:-http://rvote-online-rvote-1:3000}
- RCART_INTERNAL_URL=${RCART_INTERNAL_URL:-http://rcart-online:3000}
labels:
- "traefik.enable=true"
- "traefik.http.routers.rtrips.rule=Host(`rtrips.online`) || Host(`www.rtrips.online`)"

View File

@ -9,7 +9,8 @@
"lint": "next lint",
"db:push": "npx prisma db push",
"db:migrate": "npx prisma migrate dev",
"db:studio": "npx prisma studio"
"db:studio": "npx prisma studio",
"seed:demo": "npx tsx prisma/seed-demo.ts"
},
"dependencies": {
"@prisma/client": "^6.19.2",

193
prisma/seed-demo.ts Normal file
View File

@ -0,0 +1,193 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const DEMO_SLUG = 'alpine-explorer-2026';
async function main() {
// Idempotent — skip if already seeded
const existing = await prisma.trip.findUnique({ where: { slug: DEMO_SLUG } });
if (existing) {
console.log(`Demo trip "${DEMO_SLUG}" already exists (id: ${existing.id}). Skipping.`);
return;
}
console.log('Seeding demo trip: Alpine Explorer 2026...');
// Create demo users
const memberData = [
{ name: 'Alex', did: 'did:demo:alex-001' },
{ name: 'Sam', did: 'did:demo:sam-002' },
{ name: 'Jordan', did: 'did:demo:jordan-003' },
{ name: 'Riley', did: 'did:demo:riley-004' },
{ name: 'Casey', did: 'did:demo:casey-005' },
{ name: 'Morgan', did: 'did:demo:morgan-006' },
];
const users = await Promise.all(
memberData.map((m) =>
prisma.user.upsert({
where: { did: m.did },
update: { username: m.name },
create: { did: m.did, username: m.name },
})
)
);
// Create trip
const trip = await prisma.trip.create({
data: {
title: 'Alpine Explorer 2026',
slug: DEMO_SLUG,
description: 'Chamonix → Zermatt → Dolomites',
rawInput:
'2 weeks in the Alps with 6 friends. Hiking, via ferrata, mountain biking, kayaking, paragliding. Mix of camping, huts, and Airbnb. Budget ~€4500.',
startDate: new Date('2026-07-06'),
endDate: new Date('2026-07-20'),
budgetTotal: 4500,
budgetCurrency: 'EUR',
status: 'PLANNING',
},
});
// Add collaborators
await prisma.tripCollaborator.createMany({
data: users.map((u, i) => ({
userId: u.id,
tripId: trip.id,
role: i === 0 ? 'OWNER' : 'MEMBER',
})),
});
// Create destinations
const destinations = await Promise.all([
prisma.destination.create({
data: {
tripId: trip.id,
name: 'Chamonix',
country: 'France',
lat: 45.9237,
lng: 6.8694,
arrivalDate: new Date('2026-07-06'),
departureDate: new Date('2026-07-11'),
notes: 'Base camp for Mont Blanc region. Book mountain hut in advance.',
sortOrder: 0,
},
}),
prisma.destination.create({
data: {
tripId: trip.id,
name: 'Zermatt',
country: 'Switzerland',
lat: 46.0207,
lng: 7.7491,
arrivalDate: new Date('2026-07-12'),
departureDate: new Date('2026-07-16'),
notes: 'Car-free village. Take train from Täsch. Matterhorn views!',
sortOrder: 1,
},
}),
prisma.destination.create({
data: {
tripId: trip.id,
name: 'Dolomites',
country: 'Italy',
lat: 46.4102,
lng: 11.8440,
arrivalDate: new Date('2026-07-17'),
departureDate: new Date('2026-07-20'),
notes: 'Tre Cime di Lavaredo circuit is a must. Lake Braies for kayaking.',
sortOrder: 2,
},
}),
]);
// Create itinerary items (one per trip day)
const itineraryItems = [
{ date: '2026-07-06', title: 'Fly to Geneva', category: 'FLIGHT', destIdx: 0 },
{ date: '2026-07-07', title: 'Chamonix check-in', category: 'ACCOMMODATION', destIdx: 0 },
{ date: '2026-07-08', title: 'Mont Blanc hike', category: 'ACTIVITY', destIdx: 0 },
{ date: '2026-07-09', title: 'Rest / explore town', category: 'FREE_TIME', destIdx: 0 },
{ date: '2026-07-10', title: 'Mer de Glace trek', category: 'ACTIVITY', destIdx: 0 },
{ date: '2026-07-11', title: 'Via ferrata', category: 'ACTIVITY', destIdx: 0 },
{ date: '2026-07-12', title: 'Train to Zermatt', category: 'TRANSPORT', destIdx: 1 },
{ date: '2026-07-13', title: 'Matterhorn viewpoint', category: 'ACTIVITY', destIdx: 1 },
{ date: '2026-07-14', title: 'Mountain biking', category: 'ACTIVITY', destIdx: 1 },
{ date: '2026-07-15', title: 'Gorner Gorge hike', category: 'ACTIVITY', destIdx: 1 },
{ date: '2026-07-16', title: 'Paragliding!', category: 'ACTIVITY', destIdx: 1 },
{ date: '2026-07-17', title: 'Travel to Dolomites', category: 'TRANSPORT', destIdx: 2 },
{ date: '2026-07-18', title: 'Tre Cime circuit', category: 'ACTIVITY', destIdx: 2 },
{ date: '2026-07-19', title: 'Lake Braies kayaking', category: 'ACTIVITY', destIdx: 2 },
{ date: '2026-07-20', title: 'Fly home', category: 'FLIGHT', destIdx: 2 },
];
await prisma.itineraryItem.createMany({
data: itineraryItems.map((item, i) => ({
tripId: trip.id,
destinationId: destinations[item.destIdx].id,
title: item.title,
date: new Date(item.date),
category: item.category as never,
sortOrder: i,
})),
});
// Create expenses
const expenseData = [
{ desc: 'Geneva → Chamonix shuttle', who: 0, amount: 186, cat: 'TRANSPORT' },
{ desc: 'Mountain hut (2 nights)', who: 1, amount: 420, cat: 'ACCOMMODATION' },
{ desc: 'Via ferrata gear rental', who: 2, amount: 144, cat: 'ACTIVITY' },
{ desc: 'Groceries (Zermatt)', who: 4, amount: 93, cat: 'FOOD' },
{ desc: 'Paragliding deposit', who: 3, amount: 360, cat: 'ACTIVITY' },
];
await prisma.expense.createMany({
data: expenseData.map((e) => ({
tripId: trip.id,
paidById: users[e.who].id,
description: e.desc,
amount: e.amount,
currency: 'EUR',
category: e.cat as never,
date: new Date('2026-07-08'),
splitType: 'EQUAL',
})),
});
// Create packing items
const packingData = [
{ name: 'Hiking boots (broken in!)', cat: 'Footwear', packed: true },
{ name: 'Rain jacket & layers', cat: 'Clothing', packed: true },
{ name: 'Headlamp + spare batteries', cat: 'Gear', packed: true },
{ name: 'First aid kit', cat: 'Safety', packed: false },
{ name: 'Sunscreen SPF 50', cat: 'Personal', packed: true },
{ name: 'Trekking poles', cat: 'Gear', packed: false },
{ name: 'Refillable water bottle', cat: 'Gear', packed: true },
{ name: 'Via ferrata gloves', cat: 'Gear', packed: false },
];
await prisma.packingItem.createMany({
data: packingData.map((p, i) => ({
tripId: trip.id,
addedById: users[0].id,
name: p.name,
category: p.cat,
packed: p.packed,
sortOrder: i,
})),
});
console.log(`Demo trip created: id=${trip.id}, slug=${trip.slug}`);
console.log(` - ${users.length} collaborators`);
console.log(` - ${destinations.length} destinations`);
console.log(` - ${itineraryItems.length} itinerary items`);
console.log(` - ${expenseData.length} expenses`);
console.log(` - ${packingData.length} packing items`);
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
const RCART_BASE = process.env.RCART_INTERNAL_URL || 'http://rcart-online:3000';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const endpoint = searchParams.get('endpoint');
const slug = searchParams.get('slug');
if (!slug || !endpoint) {
return NextResponse.json({ error: 'Missing params' }, { status: 400 });
}
let targetUrl: string;
if (endpoint === 'space') {
targetUrl = `${RCART_BASE}/api/spaces/${encodeURIComponent(slug)}`;
} else if (endpoint === 'carts') {
targetUrl = `${RCART_BASE}/api/spaces/${encodeURIComponent(slug)}/carts`;
} else {
return NextResponse.json({ error: 'Invalid endpoint' }, { status: 400 });
}
try {
const res = await fetch(targetUrl, { next: { revalidate: 30 } });
if (!res.ok) {
return NextResponse.json(
{ error: `Upstream error: ${res.status}` },
{ status: res.status }
);
}
const data = await res.json();
return NextResponse.json(data);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Fetch failed' },
{ status: 502 }
);
}
}

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const RNOTES_BASE = process.env.RNOTES_INTERNAL_URL || 'http://rnotes-online:3000';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const endpoint = searchParams.get('endpoint');
const slug = searchParams.get('slug');
const notebookId = searchParams.get('notebookId');
if (!endpoint) {
return NextResponse.json({ error: 'Missing endpoint param' }, { status: 400 });
}
let targetUrl: string;
if (endpoint === 'notebook' && slug) {
// Get notebook by slug — use search with slug filter
targetUrl = `${RNOTES_BASE}/api/notebooks?slug=${encodeURIComponent(slug)}`;
} else if (endpoint === 'notes' && notebookId) {
targetUrl = `${RNOTES_BASE}/api/notebooks/${encodeURIComponent(notebookId)}/notes`;
} else {
return NextResponse.json({ error: 'Invalid endpoint or missing params' }, { status: 400 });
}
try {
const res = await fetch(targetUrl, { next: { revalidate: 30 } });
if (!res.ok) {
return NextResponse.json(
{ error: `Upstream error: ${res.status}` },
{ status: res.status }
);
}
const data = await res.json();
return NextResponse.json(data);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Fetch failed' },
{ status: 502 }
);
}
}

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
const RVOTE_BASE = process.env.RVOTE_INTERNAL_URL || 'http://rvote-online:3000';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const endpoint = searchParams.get('endpoint');
const slug = searchParams.get('slug');
if (!slug || !endpoint) {
return NextResponse.json({ error: 'Missing params' }, { status: 400 });
}
let targetUrl: string;
if (endpoint === 'space') {
targetUrl = `${RVOTE_BASE}/api/spaces/${encodeURIComponent(slug)}`;
} else if (endpoint === 'proposals') {
const status = searchParams.get('status') || 'RANKING';
targetUrl = `${RVOTE_BASE}/api/proposals?spaceSlug=${encodeURIComponent(slug)}&status=${status}&limit=10`;
} else {
return NextResponse.json({ error: 'Invalid endpoint' }, { status: 400 });
}
try {
const res = await fetch(targetUrl, { next: { revalidate: 30 } });
if (!res.ok) {
return NextResponse.json(
{ error: `Upstream error: ${res.status}` },
{ status: res.status }
);
}
const data = await res.json();
return NextResponse.json(data);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Fetch failed' },
{ status: 502 }
);
}
}

View File

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function PATCH(
_request: NextRequest,
{ params }: { params: { id: string; itemId: string } }
) {
try {
const { id, itemId } = await params;
// Verify item belongs to trip
const item = await prisma.packingItem.findFirst({
where: { id: itemId, tripId: id },
});
if (!item) {
return NextResponse.json({ error: 'Packing item not found' }, { status: 404 });
}
const updated = await prisma.packingItem.update({
where: { id: itemId },
data: { packed: !item.packed },
});
return NextResponse.json(updated);
} catch (error) {
console.error('Toggle packing item error:', error);
return NextResponse.json(
{ error: 'Failed to update packing item' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
_request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const { slug } = await params;
const trip = await prisma.trip.findUnique({
where: { slug },
include: {
destinations: { orderBy: { sortOrder: 'asc' } },
itineraryItems: { orderBy: [{ date: 'asc' }, { sortOrder: 'asc' }] },
bookings: { orderBy: { startDate: 'asc' } },
expenses: { orderBy: { date: 'desc' } },
packingItems: { orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }] },
collaborators: { include: { user: true } },
},
});
if (!trip) {
return NextResponse.json({ error: 'Trip not found' }, { status: 404 });
}
return NextResponse.json(trip);
} catch (error) {
console.error('Get trip by slug error:', error);
return NextResponse.json(
{ error: 'Failed to get trip' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,836 @@
'use client'
import Link from 'next/link'
import { useEffect, useState, useCallback } from 'react'
const DEMO_SLUG = 'alpine-explorer-2026'
/* ─── Types ─────────────────────────────────────────────────── */
interface Destination {
id: string
name: string
country: string | null
lat: number | null
lng: number | null
arrivalDate: string | null
departureDate: string | null
notes: string | null
sortOrder: number
}
interface ItineraryItem {
id: string
title: string
date: string | null
category: string
sortOrder: number
}
interface Expense {
id: string
description: string
amount: number
currency: string
category: string
paidBy: { id: string; username: string | null } | null
splitType: string
}
interface PackingItem {
id: string
name: string
category: string | null
packed: boolean
sortOrder: number
}
interface Collaborator {
id: string
role: string
user: { id: string; username: string | null }
}
interface TripData {
id: string
title: string
slug: string
description: string | null
startDate: string | null
endDate: string | null
budgetTotal: number | null
budgetCurrency: string
destinations: Destination[]
itineraryItems: ItineraryItem[]
expenses: Expense[]
packingItems: PackingItem[]
collaborators: Collaborator[]
}
/* ─── Static Fallback Data ──────────────────────────────────── */
const MEMBER_COLORS = ['bg-teal-500', 'bg-cyan-500', 'bg-blue-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500']
const FALLBACK_MEMBERS = [
{ name: 'Alex', color: 'bg-teal-500' },
{ name: 'Sam', color: 'bg-cyan-500' },
{ name: 'Jordan', color: 'bg-blue-500' },
{ name: 'Riley', color: 'bg-violet-500' },
{ name: 'Casey', color: 'bg-amber-500' },
{ name: 'Morgan', color: 'bg-rose-500' },
]
const FALLBACK_PACKING = [
{ id: 'f1', name: 'Hiking boots (broken in!)', packed: true },
{ id: 'f2', name: 'Rain jacket & layers', packed: true },
{ id: 'f3', name: 'Headlamp + spare batteries', packed: true },
{ id: 'f4', name: 'First aid kit', packed: false },
{ id: 'f5', name: 'Sunscreen SPF 50', packed: true },
{ id: 'f6', name: 'Trekking poles', packed: false },
{ id: 'f7', name: 'Refillable water bottle', packed: true },
{ id: 'f8', name: 'Via ferrata gloves', packed: false },
]
const FALLBACK_RULES = [
'Majority vote on daily activities',
'Shared expenses split equally',
'Quiet hours after 10pm in huts',
'Everyone carries their own pack',
]
const FALLBACK_CALENDAR: Record<number, { label: string; color: string }[]> = {
6: [{ label: 'Fly to Geneva', color: 'bg-teal-500' }],
7: [{ label: 'Chamonix check-in', color: 'bg-teal-500' }],
8: [{ label: 'Mont Blanc hike', color: 'bg-emerald-500' }],
9: [{ label: 'Rest / explore town', color: 'bg-slate-500' }],
10: [{ label: 'Mer de Glace trek', color: 'bg-emerald-500' }],
11: [{ label: 'Via ferrata', color: 'bg-amber-500' }],
12: [{ label: 'Train to Zermatt', color: 'bg-cyan-500' }],
13: [{ label: 'Matterhorn viewpoint', color: 'bg-emerald-500' }],
14: [{ label: 'Mountain biking', color: 'bg-violet-500' }],
15: [{ label: 'Gorner Gorge hike', color: 'bg-emerald-500' }],
16: [{ label: 'Paragliding!', color: 'bg-rose-500' }],
17: [{ label: 'Travel to Dolomites', color: 'bg-cyan-500' }],
18: [{ label: 'Tre Cime circuit', color: 'bg-emerald-500' }],
19: [{ label: 'Lake Braies kayaking', color: 'bg-blue-500' }],
20: [{ label: 'Fly home', color: 'bg-teal-500' }],
}
const FALLBACK_POLLS = [
{
question: 'Day 5 Activity?',
options: [
{ label: 'Via Ferrata', votes: 4, color: 'bg-amber-500' },
{ label: 'Kayaking', votes: 1, color: 'bg-blue-500' },
{ label: 'Rest day', votes: 1, color: 'bg-slate-500' },
],
totalVotes: 6,
},
{
question: 'Dinner tonight?',
options: [
{ label: 'Fondue place', votes: 3, color: 'bg-amber-500' },
{ label: 'Pizza by the lake', votes: 2, color: 'bg-rose-500' },
{ label: 'Cook at Airbnb', votes: 1, color: 'bg-emerald-500' },
],
totalVotes: 6,
},
]
const FALLBACK_EXPENSES = [
{ desc: 'Geneva → Chamonix shuttle', who: 'Alex', amount: 186, split: 6 },
{ desc: 'Mountain hut (2 nights)', who: 'Sam', amount: 420, split: 6 },
{ desc: 'Via ferrata gear rental', who: 'Jordan', amount: 144, split: 4 },
{ desc: 'Groceries (Zermatt)', who: 'Casey', amount: 93, split: 6 },
{ desc: 'Paragliding deposit', who: 'Riley', amount: 360, split: 3 },
]
const FALLBACK_CART = [
{ item: 'Group first-aid kit', target: 85, funded: 85, status: 'Purchased' as const },
{ item: 'Portable water filter', target: 45, funded: 45, status: 'Purchased' as const },
{ item: 'Bear canister (2x)', target: 120, funded: 90, status: 'Funding' as const },
{ item: 'Camp stove + fuel', target: 65, funded: 65, status: 'Purchased' as const },
{ item: 'Drone (group footage)', target: 350, funded: 210, status: 'Funding' as const },
{ item: 'Starlink Mini rental', target: 200, funded: 80, status: 'Funding' as const },
]
/* ─── Category → Color mapping for calendar ─────────────────── */
const CATEGORY_COLORS: Record<string, string> = {
FLIGHT: 'bg-teal-500',
TRANSPORT: 'bg-cyan-500',
ACCOMMODATION: 'bg-teal-500',
ACTIVITY: 'bg-emerald-500',
MEAL: 'bg-amber-500',
FREE_TIME: 'bg-slate-500',
OTHER: 'bg-slate-500',
}
function itineraryToCalendar(items: ItineraryItem[]): Record<number, { label: string; color: string }[]> {
const cal: Record<number, { label: string; color: string }[]> = {}
for (const item of items) {
if (!item.date) continue
const day = new Date(item.date).getUTCDate()
if (!cal[day]) cal[day] = []
cal[day].push({
label: item.title,
color: CATEGORY_COLORS[item.category] || 'bg-slate-500',
})
}
return cal
}
/* ─── Card Wrapper ──────────────────────────────────────────── */
function CardWrapper({
icon,
title,
service,
href,
span = 1,
live = false,
children,
}: {
icon: string
title: string
service: string
href: string
span?: 1 | 2
live?: boolean
children: React.ReactNode
}) {
return (
<div
className={`bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden ${
span === 2 ? 'md:col-span-2' : ''
}`}
>
<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">{icon}</span>
<span className="font-semibold text-sm">{title}</span>
{live && (
<span className="flex items-center gap-1 text-xs text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
live
</span>
)}
</div>
<a
href={`https://${href}`}
target="_blank"
rel="noopener noreferrer"
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"
>
Open in {service}
</a>
</div>
<div className="p-5">{children}</div>
</div>
)
}
/* ─── Card: rMaps ───────────────────────────────────────────── */
function RMapsCard({ destinations }: { destinations?: Destination[] }) {
// Map destinations to SVG positions (evenly spaced across SVG width)
const pins = destinations && destinations.length > 0
? destinations.map((d, i) => ({
name: d.name,
country: d.country,
cx: 160 + i * 245,
cy: 180 - i * 20,
color: ['#14b8a6', '#06b6d4', '#8b5cf6'][i] || '#94a3b8',
stroke: ['#0d9488', '#0891b2', '#7c3aed'][i] || '#64748b',
dates: d.arrivalDate && d.departureDate
? `${new Date(d.arrivalDate).toLocaleDateString('en', { month: 'short', day: 'numeric' })}${new Date(d.departureDate).getUTCDate()}`
: '',
}))
: [
{ name: 'Chamonix', country: 'France', cx: 160, cy: 180, color: '#14b8a6', stroke: '#0d9488', dates: 'Jul 611' },
{ name: 'Zermatt', country: 'Switzerland', cx: 430, cy: 150, color: '#06b6d4', stroke: '#0891b2', dates: 'Jul 1216' },
{ name: 'Dolomites', country: 'Italy', cx: 650, cy: 140, color: '#8b5cf6', stroke: '#7c3aed', dates: 'Jul 1720' },
]
const routePath = pins.length >= 3
? `M${pins[0].cx} ${pins[0].cy} C${pins[0].cx + 90} ${pins[0].cy - 20}, ${pins[1].cx - 80} ${pins[1].cy + 50}, ${pins[1].cx} ${pins[1].cy} C${pins[1].cx + 80} ${pins[1].cy - 50}, ${pins[2].cx - 90} ${pins[2].cy + 20}, ${pins[2].cx} ${pins[2].cy}`
: 'M160 180 C250 160, 350 200, 430 150 C510 100, 560 160, 650 140'
return (
<CardWrapper icon="🗺️" title="Route Map" service="rMaps" href="rmaps.online" span={2} live={!!destinations}>
<div className="relative w-full h-64 rounded-xl bg-slate-900/60 overflow-hidden">
<svg viewBox="0 0 800 300" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
{/* Mountain silhouettes */}
<path
d="M0 280 L60 200 L100 240 L160 160 L200 210 L260 130 L320 180 L380 100 L420 150 L480 80 L540 140 L600 110 L660 160 L720 120 L800 180 L800 300 L0 300 Z"
fill="rgba(30,41,59,0.8)"
/>
<path
d="M0 280 L80 220 L140 250 L200 190 L280 230 L340 170 L400 200 L460 150 L520 190 L580 160 L640 200 L700 170 L800 220 L800 300 L0 300 Z"
fill="rgba(51,65,85,0.6)"
/>
<path d="M370 100 L380 100 L390 108 L375 105 Z" fill="rgba(255,255,255,0.4)" />
<path d="M470 80 L480 80 L492 90 L476 86 Z" fill="rgba(255,255,255,0.5)" />
<path d="M590 110 L600 110 L612 120 L598 116 Z" fill="rgba(255,255,255,0.4)" />
{/* Route line */}
<path d={routePath} fill="none" stroke="rgba(94,234,212,0.7)" strokeWidth="3" strokeDasharray="10 6" />
{/* Destination pins */}
{pins.map((p) => (
<g key={p.name}>
<circle cx={p.cx} cy={p.cy} r="8" fill={p.color} stroke={p.stroke} strokeWidth="2" />
<text x={p.cx} y={p.cy + 30} textAnchor="middle" fill="#94a3b8" fontSize="12" fontWeight="600">
{p.name}
</text>
<text x={p.cx} y={p.cy + 44} textAnchor="middle" fill="#64748b" fontSize="10">
{p.dates}
</text>
</g>
))}
{/* Activity icons */}
<text x="280" y="168" fontSize="16">🥾</text>
<text x="350" y="188" fontSize="16">🧗</text>
<text x="500" y="128" fontSize="16">🚵</text>
<text x="560" y="148" fontSize="16">🪂</text>
<text x="620" y="158" fontSize="16">🛶</text>
</svg>
<div className="absolute bottom-3 left-3 flex gap-3 text-xs text-slate-400">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-teal-500" /> France</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-cyan-500" /> Switzerland</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-500" /> Italy</span>
</div>
</div>
</CardWrapper>
)
}
/* ─── Card: rNotes ──────────────────────────────────────────── */
function RNotesCard({
packingItems,
tripId,
onTogglePacked,
}: {
packingItems: { id: string; name: string; packed: boolean }[]
tripId: string | null
onTogglePacked: (itemId: string) => void
}) {
return (
<CardWrapper icon="📝" title="Trip Notes" service="rNotes" href="rnotes.online" live={!!tripId}>
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Packing Checklist
</h4>
<ul className="space-y-1.5">
{packingItems.map((item) => (
<li key={item.id} className="flex items-center gap-2 text-sm">
<button
onClick={() => onTogglePacked(item.id)}
className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center transition-colors cursor-pointer ${
item.packed
? 'bg-teal-500/20 border-teal-500 text-teal-400'
: 'border-slate-600 hover:border-slate-400'
}`}
>
{item.packed && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<span className={item.packed ? 'text-slate-400 line-through' : 'text-slate-200'}>
{item.name}
</span>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Trip Rules
</h4>
<ol className="space-y-1 text-sm text-slate-300 list-decimal list-inside">
{FALLBACK_RULES.map((rule) => (
<li key={rule}>{rule}</li>
))}
</ol>
</div>
</div>
</CardWrapper>
)
}
/* ─── Card: rCal ────────────────────────────────────────────── */
function RCalCard({ calendarEvents, live }: { calendarEvents: Record<number, { label: string; color: string }[]>; live: boolean }) {
const daysInJuly = 31
const offset = 2 // July 1, 2026 is a Wednesday (Mon-start grid)
const days = Array.from({ length: daysInJuly }, (_, i) => i + 1)
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
// Determine trip range from calendar data
const eventDays = Object.keys(calendarEvents).map(Number)
const tripStart = eventDays.length > 0 ? Math.min(...eventDays) : 6
const tripEnd = eventDays.length > 0 ? Math.max(...eventDays) : 20
return (
<CardWrapper icon="📅" title="Group Calendar" service="rCal" href="rtrips.online" span={2} live={live}>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-slate-200">July 2026</h4>
<span className="text-xs text-slate-400">{tripEnd - tripStart + 1} days 3 countries</span>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{dayNames.map((d) => (
<div key={d} className="text-center text-xs text-slate-500 font-medium py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: offset }).map((_, i) => (
<div key={`empty-${i}`} className="h-14" />
))}
{days.map((day) => {
const events = calendarEvents[day]
const isTrip = day >= tripStart && day <= tripEnd
return (
<div
key={day}
className={`h-14 rounded-lg p-1 text-xs ${
isTrip ? 'bg-slate-700/40 border border-slate-600/40' : 'bg-slate-800/30'
}`}
>
<span className={isTrip ? 'text-slate-200 font-medium' : 'text-slate-500'}>{day}</span>
{events?.map((e) => (
<div key={e.label} className={`${e.color} rounded px-1 py-0.5 text-white truncate mt-0.5`} style={{ fontSize: '9px' }}>
{e.label}
</div>
))}
</div>
)
})}
</div>
<div className="flex flex-wrap gap-3 mt-3 text-xs text-slate-400">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-teal-500" /> Travel</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-500" /> Hiking</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-500" /> Adventure</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-500" /> Biking</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-rose-500" /> Extreme</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-500" /> Water</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-cyan-500" /> Transit</span>
</div>
</div>
</CardWrapper>
)
}
/* ─── Card: rVote ───────────────────────────────────────────── */
function RVoteCard({ polls }: { polls: typeof FALLBACK_POLLS; live: boolean }) {
return (
<CardWrapper icon="🗳️" title="Group Polls" service="rVote" href="rvote.online" live={polls !== FALLBACK_POLLS}>
<div className="space-y-5">
{polls.map((poll) => (
<div key={poll.question}>
<h4 className="text-sm font-medium text-slate-200 mb-2">{poll.question}</h4>
<div className="space-y-2">
{poll.options.map((opt) => {
const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0
return (
<div key={opt.label}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-slate-300">{opt.label}</span>
<span className="text-slate-400">
{opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%)
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full ${opt.color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
</div>
<p className="text-xs text-slate-500 mt-1">{poll.totalVotes} votes cast</p>
</div>
))}
</div>
</CardWrapper>
)
}
/* ─── Card: rFunds ──────────────────────────────────────────── */
function RFundsCard({
expenses,
members,
live,
}: {
expenses: { desc: string; who: string; amount: number; split: number }[]
members: { name: string; color: string }[]
live: boolean
}) {
const totalSpent = expenses.reduce((s, e) => s + e.amount, 0)
const balances: Record<string, number> = {}
members.forEach((m) => (balances[m.name] = 0))
expenses.forEach((e) => {
const share = e.amount / e.split
balances[e.who] = (balances[e.who] || 0) + e.amount
members.slice(0, e.split).forEach((m) => {
balances[m.name] = (balances[m.name] || 0) - share
})
})
return (
<CardWrapper icon="💰" title="Group Expenses" service="rFunds" href="rfunds.online" live={live}>
<div className="space-y-4">
<div className="text-center py-2">
<p className="text-2xl font-bold text-white">{totalSpent.toLocaleString()}</p>
<p className="text-xs text-slate-400">Total group spending</p>
</div>
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Recent</h4>
<div className="space-y-2">
{expenses.slice(0, 4).map((e) => (
<div key={e.desc} className="flex items-center justify-between text-sm">
<div className="min-w-0">
<p className="text-slate-200 truncate">{e.desc}</p>
<p className="text-xs text-slate-500">{e.who} split {e.split} ways</p>
</div>
<span className="text-slate-300 font-medium ml-2 flex-shrink-0">{e.amount}</span>
</div>
))}
</div>
</div>
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Balances</h4>
<div className="grid grid-cols-2 gap-1.5">
{members.map((m) => {
const bal = balances[m.name] || 0
return (
<div key={m.name} className="flex items-center justify-between text-xs">
<span className="text-slate-300">{m.name}</span>
<span className={bal >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
{bal >= 0 ? '+' : ''}{Math.round(bal)}
</span>
</div>
)
})}
</div>
</div>
</div>
</CardWrapper>
)
}
/* ─── Card: rCart ────────────────────────────────────────────── */
function RCartCard({ cartItems, live }: { cartItems: typeof FALLBACK_CART; live: boolean }) {
const totalFunded = cartItems.reduce((s, i) => s + i.funded, 0)
const totalTarget = cartItems.reduce((s, i) => s + i.target, 0)
return (
<CardWrapper icon="🛒" title="Shared Gear" service="rCart" href="rcart.online" span={2} live={live}>
<div className="space-y-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-300">{totalFunded} / {totalTarget} funded</span>
<span className="text-xs text-slate-400">
{cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden mb-4">
<div className="h-full bg-teal-500 rounded-full" style={{ width: `${totalTarget > 0 ? Math.round((totalFunded / totalTarget) * 100) : 0}%` }} />
</div>
<div className="grid sm:grid-cols-2 gap-3">
{cartItems.map((item) => {
const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0
return (
<div key={item.item} className="bg-slate-700/30 rounded-lg p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm text-slate-200">{item.item}</span>
{item.status === 'Purchased' ? (
<span className="text-xs px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full"> Bought</span>
) : (
<span className="text-xs text-slate-400">{item.funded}/{item.target}</span>
)}
</div>
<div className="h-1.5 bg-slate-600 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${pct === 100 ? 'bg-emerald-500' : 'bg-teal-500'}`} style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
</div>
</div>
</CardWrapper>
)
}
/* ─── Main Demo Content ─────────────────────────────────────── */
export default function DemoContent() {
const [trip, setTrip] = useState<TripData | null>(null)
const [packingItems, setPackingItems] = useState(FALLBACK_PACKING)
const [calendarEvents, setCalendarEvents] = useState(FALLBACK_CALENDAR)
const [polls, setPolls] = useState(FALLBACK_POLLS)
const [expenseData, setExpenseData] = useState(FALLBACK_EXPENSES)
const [cartData, setCartData] = useState(FALLBACK_CART)
const [members, setMembers] = useState(FALLBACK_MEMBERS)
const [liveFlags, setLiveFlags] = useState({ trip: false, polls: false, cart: false })
// Fetch trip data (native rTrips)
useEffect(() => {
fetch(`/api/trips/by-slug/${DEMO_SLUG}`)
.then((r) => {
if (!r.ok) throw new Error(`${r.status}`)
return r.json()
})
.then((data: TripData) => {
setTrip(data)
// Packing items
if (data.packingItems?.length > 0) {
setPackingItems(data.packingItems.map((p) => ({ id: p.id, name: p.name, packed: p.packed })))
}
// Calendar from itinerary
if (data.itineraryItems?.length > 0) {
setCalendarEvents(itineraryToCalendar(data.itineraryItems))
}
// Expenses
if (data.expenses?.length > 0) {
setExpenseData(
data.expenses.map((e) => ({
desc: e.description,
who: e.paidBy?.username || 'Unknown',
amount: e.amount,
split: 6, // All demo expenses are EQUAL split among 6
}))
)
}
// Members from collaborators
if (data.collaborators?.length > 0) {
setMembers(
data.collaborators.map((c, i) => ({
name: c.user.username || `User ${i + 1}`,
color: MEMBER_COLORS[i % MEMBER_COLORS.length],
}))
)
}
setLiveFlags((f) => ({ ...f, trip: true }))
})
.catch(() => {
// Keep fallback data
})
}, [])
// Fetch rVote polls
useEffect(() => {
fetch(`/api/proxy/rvote?endpoint=proposals&slug=${DEMO_SLUG}`)
.then((r) => {
if (!r.ok) throw new Error(`${r.status}`)
return r.json()
})
.then((data) => {
if (data.proposals && data.proposals.length > 0) {
// Transform rVote proposals to poll format
setPolls(
data.proposals.slice(0, 2).map((p: Record<string, unknown>) => ({
question: p.title as string,
options: (p.description as string || '').split('\n').filter(Boolean).map((opt: string, i: number) => ({
label: opt,
votes: Math.max(1, Math.floor(Math.random() * 5)),
color: ['bg-amber-500', 'bg-blue-500', 'bg-emerald-500', 'bg-rose-500'][i % 4],
})),
totalVotes: 6,
}))
)
setLiveFlags((f) => ({ ...f, polls: true }))
}
})
.catch(() => {
// Keep fallback polls
})
}, [])
// Fetch rCart data
useEffect(() => {
fetch(`/api/proxy/rcart?endpoint=carts&slug=${DEMO_SLUG}`)
.then((r) => {
if (!r.ok) throw new Error(`${r.status}`)
return r.json()
})
.then((data) => {
if (Array.isArray(data) && data.length > 0) {
const cart = data[0]
if (cart.items?.length > 0) {
setCartData(
cart.items.map((item: Record<string, unknown>) => ({
item: item.name as string,
target: (item.targetAmount as number) || 100,
funded: (item.fundedAmount as number) || 0,
status: ((item.fundedAmount as number) || 0) >= ((item.targetAmount as number) || 100) ? 'Purchased' : 'Funding',
}))
)
setLiveFlags((f) => ({ ...f, cart: true }))
}
}
})
.catch(() => {
// Keep fallback cart
})
}, [])
// Toggle packing item
const handleTogglePacked = useCallback(
(itemId: string) => {
// Optimistic update
setPackingItems((items) => items.map((i) => (i.id === itemId ? { ...i, packed: !i.packed } : i)))
// Persist if we have a real trip
if (trip?.id) {
fetch(`/api/trips/${trip.id}/packing/${itemId}`, { method: 'PATCH' }).catch(() => {
// Revert on error
setPackingItems((items) => items.map((i) => (i.id === itemId ? { ...i, packed: !i.packed } : i)))
})
}
},
[trip?.id]
)
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-7xl 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-teal-400 to-cyan-500 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
rT
</div>
<span className="font-semibold text-lg">rTrips</span>
</Link>
<span className="text-slate-600">/</span>
<span className="text-sm text-slate-400">Demo</span>
</div>
<Link
href="/trips/new"
className="text-sm px-4 py-2 bg-teal-600 hover:bg-teal-500 rounded-lg transition-colors font-medium"
>
Plan Your Own Trip
</Link>
</div>
</nav>
{/* Trip Header */}
<section className="max-w-7xl 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-teal-300 via-cyan-300 to-blue-300 bg-clip-text text-transparent">
{trip?.title || 'Alpine Explorer 2026'}
</h1>
<p className="text-lg text-slate-300 mb-2">
{trip?.description || 'Chamonix → Zermatt → Dolomites'}
</p>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
<span>📅 Jul 620, 2026</span>
<span>💶 ~{trip?.budgetTotal?.toLocaleString() || '4,500'} budget</span>
<span>🏔 3 countries</span>
{liveFlags.trip && (
<span className="flex items-center gap-1 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Live data
</span>
)}
</div>
{/* Member avatars */}
<div className="flex items-center justify-center gap-2">
{members.map((m) => (
<div
key={m.name}
className={`w-10 h-10 ${m.color} rounded-full flex items-center justify-center text-sm font-bold text-white ring-2 ring-slate-800`}
title={m.name}
>
{m.name[0]}
</div>
))}
<span className="text-sm text-slate-400 ml-2">{members.length} explorers</span>
</div>
</div>
</section>
{/* rStack Intro */}
<section className="max-w-7xl mx-auto px-6 pb-6">
<p className="text-center text-sm text-slate-400 max-w-2xl mx-auto">
Every trip is powered by the <span className="text-slate-200 font-medium">rStack</span> a suite
of collaborative tools that handle routes, notes, schedules, voting, expenses, and shared
purchases. Each card below shows live data with a link to the full tool.
</p>
</section>
{/* Canvas Grid */}
<section className="max-w-7xl mx-auto px-6 pb-16">
<div className="grid md:grid-cols-3 gap-4">
<RMapsCard destinations={trip?.destinations} />
<RNotesCard packingItems={packingItems} tripId={trip?.id || null} onTogglePacked={handleTogglePacked} />
<RCalCard calendarEvents={calendarEvents} live={liveFlags.trip} />
<RVoteCard polls={polls} live={liveFlags.polls} />
<RFundsCard expenses={expenseData} members={members} live={liveFlags.trip} />
<RCartCard cartItems={cartData} live={liveFlags.cart} />
</div>
</section>
{/* Bottom CTA */}
<section className="max-w-7xl mx-auto px-6 pb-20 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">Plan Your Own Group Adventure</h2>
<p className="text-slate-400 mb-6 max-w-lg mx-auto">
The rStack gives your group everything you need routes, schedules, polls, shared
expenses, and gear lists all connected in one trip canvas.
</p>
<Link
href="/trips/new"
className="inline-block px-8 py-4 bg-teal-600 hover:bg-teal-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-teal-900/30"
>
Start Planning
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-700/50 py-8">
<div className="max-w-7xl 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">rStack</span>
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors"> rTrips</a>
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">🗺 rMaps</a>
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">📝 rNotes</a>
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">🗳 rVote</a>
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">💰 rFunds</a>
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">🛒 rCart</a>
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">🌌 rSpace</a>
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">📁 rFiles</a>
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">💼 rWallet</a>
</div>
<p className="text-center text-xs text-slate-600">
Collaborative trip planning for groups that love adventure.
</p>
</div>
</footer>
</div>
)
}

View File

@ -1,633 +1,11 @@
import Link from 'next/link'
import type { Metadata } from 'next'
import DemoContent from './demo-content'
export const metadata: Metadata = {
title: 'rTrips Demo - Alpine Explorer 2026',
description: 'See how rTrips and the rStack ecosystem power collaborative trip planning. A demo showcasing rMaps, rNotes, rCal, rVote, rFunds, and rCart working together.',
}
/* ─── Mock Data ─────────────────────────────────────────────── */
const members = [
{ name: 'Alex', color: 'bg-teal-500' },
{ name: 'Sam', color: 'bg-cyan-500' },
{ name: 'Jordan', color: 'bg-blue-500' },
{ name: 'Riley', color: 'bg-violet-500' },
{ name: 'Casey', color: 'bg-amber-500' },
{ name: 'Morgan', color: 'bg-rose-500' },
]
const packingList = [
{ item: 'Hiking boots (broken in!)', checked: true },
{ item: 'Rain jacket & layers', checked: true },
{ item: 'Headlamp + spare batteries', checked: true },
{ item: 'First aid kit', checked: false },
{ item: 'Sunscreen SPF 50', checked: true },
{ item: 'Trekking poles', checked: false },
{ item: 'Refillable water bottle', checked: true },
{ item: 'Via ferrata gloves', checked: false },
]
const tripRules = [
'Majority vote on daily activities',
'Shared expenses split equally',
'Quiet hours after 10pm in huts',
'Everyone carries their own pack',
]
const calendarEvents: Record<number, { label: string; color: string }[]> = {
6: [{ label: 'Fly to Geneva', color: 'bg-teal-500' }],
7: [{ label: 'Chamonix check-in', color: 'bg-teal-500' }],
8: [{ label: 'Mont Blanc hike', color: 'bg-emerald-500' }],
9: [{ label: 'Rest / explore town', color: 'bg-slate-500' }],
10: [{ label: 'Mer de Glace trek', color: 'bg-emerald-500' }],
11: [{ label: 'Via ferrata', color: 'bg-amber-500' }],
12: [{ label: 'Train to Zermatt', color: 'bg-cyan-500' }],
13: [{ label: 'Matterhorn viewpoint', color: 'bg-emerald-500' }],
14: [{ label: 'Mountain biking', color: 'bg-violet-500' }],
15: [{ label: 'Gorner Gorge hike', color: 'bg-emerald-500' }],
16: [{ label: 'Paragliding!', color: 'bg-rose-500' }],
17: [{ label: 'Travel to Dolomites', color: 'bg-cyan-500' }],
18: [{ label: 'Tre Cime circuit', color: 'bg-emerald-500' }],
19: [{ label: 'Lake Braies kayaking', color: 'bg-blue-500' }],
20: [{ label: 'Fly home', color: 'bg-teal-500' }],
}
const polls = [
{
question: 'Day 5 Activity?',
options: [
{ label: 'Via Ferrata', votes: 4, color: 'bg-amber-500' },
{ label: 'Kayaking', votes: 1, color: 'bg-blue-500' },
{ label: 'Rest day', votes: 1, color: 'bg-slate-500' },
],
totalVotes: 6,
},
{
question: 'Dinner tonight?',
options: [
{ label: 'Fondue place', votes: 3, color: 'bg-amber-500' },
{ label: 'Pizza by the lake', votes: 2, color: 'bg-rose-500' },
{ label: 'Cook at Airbnb', votes: 1, color: 'bg-emerald-500' },
],
totalVotes: 6,
},
]
const expenses = [
{ desc: 'Geneva → Chamonix shuttle', who: 'Alex', amount: 186, split: 6 },
{ desc: 'Mountain hut (2 nights)', who: 'Sam', amount: 420, split: 6 },
{ desc: 'Via ferrata gear rental', who: 'Jordan', amount: 144, split: 4 },
{ desc: 'Groceries (Zermatt)', who: 'Casey', amount: 93, split: 6 },
{ desc: 'Paragliding deposit', who: 'Riley', amount: 360, split: 3 },
]
const cartItems = [
{ item: 'Group first-aid kit', target: 85, funded: 85, status: 'Purchased' as const },
{ item: 'Portable water filter', target: 45, funded: 45, status: 'Purchased' as const },
{ item: 'Bear canister (2x)', target: 120, funded: 90, status: 'Funding' as const },
{ item: 'Camp stove + fuel', target: 65, funded: 65, status: 'Purchased' as const },
{ item: 'Drone (group footage)', target: 350, funded: 210, status: 'Funding' as const },
{ item: 'Starlink Mini rental', target: 200, funded: 80, status: 'Funding' as const },
]
/* ─── Card Wrapper ──────────────────────────────────────────── */
function CardWrapper({
icon,
title,
service,
href,
span = 1,
children,
}: {
icon: string
title: string
service: string
href: string
span?: 1 | 2
children: React.ReactNode
}) {
return (
<div
className={`bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden ${
span === 2 ? 'md:col-span-2' : ''
}`}
>
<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">{icon}</span>
<span className="font-semibold text-sm">{title}</span>
</div>
<a
href={`https://${href}`}
target="_blank"
rel="noopener noreferrer"
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"
>
Open in {service}
</a>
</div>
<div className="p-5">{children}</div>
</div>
)
}
/* ─── Card: rMaps ───────────────────────────────────────────── */
function RMapsCard() {
return (
<CardWrapper icon="🗺️" title="Route Map" service="rMaps" href="rmaps.online" span={2}>
<div className="relative w-full h-64 rounded-xl bg-slate-900/60 overflow-hidden">
<svg viewBox="0 0 800 300" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
{/* Mountain silhouettes */}
<path
d="M0 280 L60 200 L100 240 L160 160 L200 210 L260 130 L320 180 L380 100 L420 150 L480 80 L540 140 L600 110 L660 160 L720 120 L800 180 L800 300 L0 300 Z"
fill="rgba(30,41,59,0.8)"
/>
<path
d="M0 280 L80 220 L140 250 L200 190 L280 230 L340 170 L400 200 L460 150 L520 190 L580 160 L640 200 L700 170 L800 220 L800 300 L0 300 Z"
fill="rgba(51,65,85,0.6)"
/>
{/* Snow caps */}
<path d="M370 100 L380 100 L390 108 L375 105 Z" fill="rgba(255,255,255,0.4)" />
<path d="M470 80 L480 80 L492 90 L476 86 Z" fill="rgba(255,255,255,0.5)" />
<path d="M590 110 L600 110 L612 120 L598 116 Z" fill="rgba(255,255,255,0.4)" />
{/* Route line */}
<path
d="M160 180 C250 160, 350 200, 430 150 C510 100, 560 160, 650 140"
fill="none"
stroke="rgba(94,234,212,0.7)"
strokeWidth="3"
strokeDasharray="10 6"
/>
{/* Destination pins */}
{/* Chamonix */}
<circle cx="160" cy="180" r="8" fill="#14b8a6" stroke="#0d9488" strokeWidth="2" />
<text x="160" y="210" textAnchor="middle" fill="#94a3b8" fontSize="12" fontWeight="600">
Chamonix
</text>
<text x="160" y="224" textAnchor="middle" fill="#64748b" fontSize="10">
Jul 611
</text>
{/* Zermatt */}
<circle cx="430" cy="150" r="8" fill="#06b6d4" stroke="#0891b2" strokeWidth="2" />
<text x="430" y="180" textAnchor="middle" fill="#94a3b8" fontSize="12" fontWeight="600">
Zermatt
</text>
<text x="430" y="194" textAnchor="middle" fill="#64748b" fontSize="10">
Jul 1216
</text>
{/* Dolomites */}
<circle cx="650" cy="140" r="8" fill="#8b5cf6" stroke="#7c3aed" strokeWidth="2" />
<text x="650" y="170" textAnchor="middle" fill="#94a3b8" fontSize="12" fontWeight="600">
Dolomites
</text>
<text x="650" y="184" textAnchor="middle" fill="#64748b" fontSize="10">
Jul 1720
</text>
{/* Activity icons along route */}
<text x="280" y="168" fontSize="16">🥾</text>
<text x="350" y="188" fontSize="16">🧗</text>
<text x="500" y="128" fontSize="16">🚵</text>
<text x="560" y="148" fontSize="16">🪂</text>
<text x="620" y="158" fontSize="16">🛶</text>
</svg>
{/* Legend */}
<div className="absolute bottom-3 left-3 flex gap-3 text-xs text-slate-400">
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-teal-500" /> France
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-cyan-500" /> Switzerland
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-violet-500" /> Italy
</span>
</div>
</div>
</CardWrapper>
)
}
/* ─── Card: rNotes ──────────────────────────────────────────── */
function RNotesCard() {
return (
<CardWrapper icon="📝" title="Trip Notes" service="rNotes" href="rnotes.online">
<div className="space-y-4">
{/* Packing list */}
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Packing Checklist
</h4>
<ul className="space-y-1.5">
{packingList.map((item) => (
<li key={item.item} className="flex items-center gap-2 text-sm">
<span
className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center ${
item.checked
? 'bg-teal-500/20 border-teal-500 text-teal-400'
: 'border-slate-600'
}`}
>
{item.checked && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</span>
<span className={item.checked ? 'text-slate-400 line-through' : 'text-slate-200'}>
{item.item}
</span>
</li>
))}
</ul>
</div>
{/* Trip rules */}
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Trip Rules
</h4>
<ol className="space-y-1 text-sm text-slate-300 list-decimal list-inside">
{tripRules.map((rule) => (
<li key={rule}>{rule}</li>
))}
</ol>
</div>
</div>
</CardWrapper>
)
}
/* ─── Card: rCal ────────────────────────────────────────────── */
function RCalCard() {
const daysInJuly = 31
const startDay = 0 // July 2026 starts on Wednesday (0=Mon grid: Wed=2, but let's use simple offset)
// July 1, 2026 is a Wednesday. In a Mon-start grid, offset = 2
const offset = 2
const days = Array.from({ length: daysInJuly }, (_, i) => i + 1)
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
return (
<CardWrapper icon="📅" title="Group Calendar" service="rCal" href="rtrips.online" span={2}>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-slate-200">July 2026</h4>
<span className="text-xs text-slate-400">15 days 3 countries</span>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{dayNames.map((d) => (
<div key={d} className="text-center text-xs text-slate-500 font-medium py-1">
{d}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{/* Empty offset cells */}
{Array.from({ length: offset }).map((_, i) => (
<div key={`empty-${i}`} className="h-14" />
))}
{days.map((day) => {
const events = calendarEvents[day]
const isTrip = day >= 6 && day <= 20
return (
<div
key={day}
className={`h-14 rounded-lg p-1 text-xs ${
isTrip
? 'bg-slate-700/40 border border-slate-600/40'
: 'bg-slate-800/30'
}`}
>
<span className={`${isTrip ? 'text-slate-200 font-medium' : 'text-slate-500'}`}>
{day}
</span>
{events?.map((e) => (
<div
key={e.label}
className={`${e.color} rounded px-1 py-0.5 text-white truncate mt-0.5`}
style={{ fontSize: '9px' }}
>
{e.label}
</div>
))}
</div>
)
})}
</div>
{/* Activity legend */}
<div className="flex flex-wrap gap-3 mt-3 text-xs text-slate-400">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-teal-500" /> Travel</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-500" /> Hiking</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-500" /> Adventure</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-500" /> Biking</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-rose-500" /> Extreme</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-500" /> Water</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-cyan-500" /> Transit</span>
</div>
</div>
</CardWrapper>
)
}
/* ─── Card: rVote ───────────────────────────────────────────── */
function RVoteCard() {
return (
<CardWrapper icon="🗳️" title="Group Polls" service="rVote" href="rvote.online">
<div className="space-y-5">
{polls.map((poll) => (
<div key={poll.question}>
<h4 className="text-sm font-medium text-slate-200 mb-2">{poll.question}</h4>
<div className="space-y-2">
{poll.options.map((opt) => {
const pct = Math.round((opt.votes / poll.totalVotes) * 100)
return (
<div key={opt.label}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-slate-300">{opt.label}</span>
<span className="text-slate-400">
{opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%)
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full ${opt.color} rounded-full transition-all`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
<p className="text-xs text-slate-500 mt-1">{poll.totalVotes} votes cast</p>
</div>
))}
</div>
</CardWrapper>
)
}
/* ─── Card: rFunds ──────────────────────────────────────────── */
function RFundsCard() {
const totalSpent = expenses.reduce((s, e) => s + e.amount, 0)
// Calculate balances per member
const balances: Record<string, number> = {}
members.forEach((m) => (balances[m.name] = 0))
expenses.forEach((e) => {
const share = e.amount / e.split
balances[e.who] = (balances[e.who] || 0) + e.amount // they paid
// Everyone who splits owes their share
members.slice(0, e.split).forEach((m) => {
balances[m.name] = (balances[m.name] || 0) - share
})
})
return (
<CardWrapper icon="💰" title="Group Expenses" service="rFunds" href="rfunds.online">
<div className="space-y-4">
{/* Total */}
<div className="text-center py-2">
<p className="text-2xl font-bold text-white">{totalSpent.toLocaleString()}</p>
<p className="text-xs text-slate-400">Total group spending</p>
</div>
{/* Recent transactions */}
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Recent
</h4>
<div className="space-y-2">
{expenses.slice(0, 4).map((e) => (
<div key={e.desc} className="flex items-center justify-between text-sm">
<div className="min-w-0">
<p className="text-slate-200 truncate">{e.desc}</p>
<p className="text-xs text-slate-500">
{e.who} split {e.split} ways
</p>
</div>
<span className="text-slate-300 font-medium ml-2 flex-shrink-0">{e.amount}</span>
</div>
))}
</div>
</div>
{/* Balances */}
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Balances
</h4>
<div className="grid grid-cols-2 gap-1.5">
{members.map((m) => {
const bal = balances[m.name] || 0
return (
<div key={m.name} className="flex items-center justify-between text-xs">
<span className="text-slate-300">{m.name}</span>
<span className={bal >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
{bal >= 0 ? '+' : ''}{Math.round(bal)}
</span>
</div>
)
})}
</div>
</div>
</div>
</CardWrapper>
)
}
/* ─── Card: rCart ────────────────────────────────────────────── */
function RCartCard() {
const totalFunded = cartItems.reduce((s, i) => s + i.funded, 0)
const totalTarget = cartItems.reduce((s, i) => s + i.target, 0)
return (
<CardWrapper icon="🛒" title="Shared Gear" service="rCart" href="rcart.online" span={2}>
<div className="space-y-3">
{/* Summary bar */}
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-300">
{totalFunded} / {totalTarget} funded
</span>
<span className="text-xs text-slate-400">
{cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased
</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-teal-500 rounded-full"
style={{ width: `${Math.round((totalFunded / totalTarget) * 100)}%` }}
/>
</div>
{/* Items grid */}
<div className="grid sm:grid-cols-2 gap-3">
{cartItems.map((item) => {
const pct = Math.round((item.funded / item.target) * 100)
return (
<div key={item.item} className="bg-slate-700/30 rounded-lg p-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm text-slate-200">{item.item}</span>
{item.status === 'Purchased' ? (
<span className="text-xs px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full">
Bought
</span>
) : (
<span className="text-xs text-slate-400">{item.funded}/{item.target}</span>
)}
</div>
<div className="h-1.5 bg-slate-600 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
pct === 100 ? 'bg-emerald-500' : 'bg-teal-500'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
</CardWrapper>
)
}
/* ─── Page ──────────────────────────────────────────────────── */
export default function DemoPage() {
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-7xl 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-teal-400 to-cyan-500 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
rT
</div>
<span className="font-semibold text-lg">rTrips</span>
</Link>
<span className="text-slate-600">/</span>
<span className="text-sm text-slate-400">Demo</span>
</div>
<Link
href="/trips/new"
className="text-sm px-4 py-2 bg-teal-600 hover:bg-teal-500 rounded-lg transition-colors font-medium"
>
Plan Your Own Trip
</Link>
</div>
</nav>
{/* Trip Header */}
<section className="max-w-7xl 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-teal-300 via-cyan-300 to-blue-300 bg-clip-text text-transparent">
Alpine Explorer 2026
</h1>
<p className="text-lg text-slate-300 mb-2">
Chamonix Zermatt Dolomites
</p>
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
<span>📅 Jul 620, 2026</span>
<span>💶 ~4,500 budget</span>
<span>🏔 3 countries</span>
</div>
{/* Member avatars */}
<div className="flex items-center justify-center gap-2">
{members.map((m) => (
<div
key={m.name}
className={`w-10 h-10 ${m.color} rounded-full flex items-center justify-center text-sm font-bold text-white ring-2 ring-slate-800`}
title={m.name}
>
{m.name[0]}
</div>
))}
<span className="text-sm text-slate-400 ml-2">6 explorers</span>
</div>
</div>
</section>
{/* rStack Intro */}
<section className="max-w-7xl mx-auto px-6 pb-6">
<p className="text-center text-sm text-slate-400 max-w-2xl mx-auto">
Every trip is powered by the <span className="text-slate-200 font-medium">rStack</span> a suite
of collaborative tools that handle routes, notes, schedules, voting, expenses, and shared
purchases. Each card below is a live preview with a link to the full tool.
</p>
</section>
{/* Canvas Grid */}
<section className="max-w-7xl mx-auto px-6 pb-16">
<div className="grid md:grid-cols-3 gap-4">
<RMapsCard />
<RNotesCard />
<RCalCard />
<RVoteCard />
<RFundsCard />
<RCartCard />
</div>
</section>
{/* Bottom CTA */}
<section className="max-w-7xl mx-auto px-6 pb-20 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">Plan Your Own Group Adventure</h2>
<p className="text-slate-400 mb-6 max-w-lg mx-auto">
The rStack gives your group everything you need routes, schedules, polls, shared
expenses, and gear lists all connected in one trip canvas.
</p>
<Link
href="/trips/new"
className="inline-block px-8 py-4 bg-teal-600 hover:bg-teal-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-teal-900/30"
>
Start Planning
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-slate-700/50 py-8">
<div className="max-w-7xl 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">🌌 rSpace</a>
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">🗺 rMaps</a>
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">📝 rNotes</a>
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">🗳 rVote</a>
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">💰 rFunds</a>
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300"> rTrips</a>
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">🛒 rCart</a>
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">💼 rWallet</a>
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">📁 rFiles</a>
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">🌐 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>
)
return <DemoContent />
}