diff --git a/docker-compose.yml b/docker-compose.yml index b45882b..931aa39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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`)" diff --git a/package.json b/package.json index b706046..77af786 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/seed-demo.ts b/prisma/seed-demo.ts new file mode 100644 index 0000000..f4a9d23 --- /dev/null +++ b/prisma/seed-demo.ts @@ -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()); diff --git a/src/app/api/proxy/rcart/route.ts b/src/app/api/proxy/rcart/route.ts new file mode 100644 index 0000000..e503901 --- /dev/null +++ b/src/app/api/proxy/rcart/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/proxy/rnotes/route.ts b/src/app/api/proxy/rnotes/route.ts new file mode 100644 index 0000000..ad831a0 --- /dev/null +++ b/src/app/api/proxy/rnotes/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/proxy/rvote/route.ts b/src/app/api/proxy/rvote/route.ts new file mode 100644 index 0000000..9926081 --- /dev/null +++ b/src/app/api/proxy/rvote/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/trips/[id]/packing/[itemId]/route.ts b/src/app/api/trips/[id]/packing/[itemId]/route.ts new file mode 100644 index 0000000..271a279 --- /dev/null +++ b/src/app/api/trips/[id]/packing/[itemId]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/trips/by-slug/[slug]/route.ts b/src/app/api/trips/by-slug/[slug]/route.ts new file mode 100644 index 0000000..a9df354 --- /dev/null +++ b/src/app/api/trips/by-slug/[slug]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/demo/demo-content.tsx b/src/app/demo/demo-content.tsx new file mode 100644 index 0000000..a882399 --- /dev/null +++ b/src/app/demo/demo-content.tsx @@ -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 = { + 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 = { + 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 { + const cal: Record = {} + 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 ( +
+
+
+ {icon} + {title} + {live && ( + + + live + + )} +
+ + Open in {service} ↗ + +
+
{children}
+
+ ) +} + +/* ─── 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 6–11' }, + { name: 'Zermatt', country: 'Switzerland', cx: 430, cy: 150, color: '#06b6d4', stroke: '#0891b2', dates: 'Jul 12–16' }, + { name: 'Dolomites', country: 'Italy', cx: 650, cy: 140, color: '#8b5cf6', stroke: '#7c3aed', dates: 'Jul 17–20' }, + ] + + 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 ( + +
+ + {/* Mountain silhouettes */} + + + + + + + {/* Route line */} + + + {/* Destination pins */} + {pins.map((p) => ( + + + + {p.name} + + + {p.dates} + + + ))} + + {/* Activity icons */} + 🥾 + 🧗 + 🚵 + 🪂 + 🛶 + + +
+ France + Switzerland + Italy +
+
+
+ ) +} + +/* ─── Card: rNotes ──────────────────────────────────────────── */ + +function RNotesCard({ + packingItems, + tripId, + onTogglePacked, +}: { + packingItems: { id: string; name: string; packed: boolean }[] + tripId: string | null + onTogglePacked: (itemId: string) => void +}) { + return ( + +
+
+

+ Packing Checklist +

+
    + {packingItems.map((item) => ( +
  • + + + {item.name} + +
  • + ))} +
+
+ +
+

+ Trip Rules +

+
    + {FALLBACK_RULES.map((rule) => ( +
  1. {rule}
  2. + ))} +
+
+
+
+ ) +} + +/* ─── Card: rCal ────────────────────────────────────────────── */ + +function RCalCard({ calendarEvents, live }: { calendarEvents: Record; 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 ( + +
+
+

July 2026

+ {tripEnd - tripStart + 1} days • 3 countries +
+ +
+ {dayNames.map((d) => ( +
{d}
+ ))} +
+ +
+ {Array.from({ length: offset }).map((_, i) => ( +
+ ))} + {days.map((day) => { + const events = calendarEvents[day] + const isTrip = day >= tripStart && day <= tripEnd + return ( +
+ {day} + {events?.map((e) => ( +
+ {e.label} +
+ ))} +
+ ) + })} +
+ +
+ Travel + Hiking + Adventure + Biking + Extreme + Water + Transit +
+
+ + ) +} + +/* ─── Card: rVote ───────────────────────────────────────────── */ + +function RVoteCard({ polls }: { polls: typeof FALLBACK_POLLS; live: boolean }) { + return ( + +
+ {polls.map((poll) => ( +
+

{poll.question}

+
+ {poll.options.map((opt) => { + const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0 + return ( +
+
+ {opt.label} + + {opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%) + +
+
+
+
+
+ ) + })} +
+

{poll.totalVotes} votes cast

+
+ ))} +
+ + ) +} + +/* ─── 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 = {} + 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 ( + +
+
+

€{totalSpent.toLocaleString()}

+

Total group spending

+
+ +
+

Recent

+
+ {expenses.slice(0, 4).map((e) => ( +
+
+

{e.desc}

+

{e.who} • split {e.split} ways

+
+ €{e.amount} +
+ ))} +
+
+ +
+

Balances

+
+ {members.map((m) => { + const bal = balances[m.name] || 0 + return ( +
+ {m.name} + = 0 ? 'text-emerald-400' : 'text-rose-400'}> + {bal >= 0 ? '+' : ''}€{Math.round(bal)} + +
+ ) + })} +
+
+
+
+ ) +} + +/* ─── 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 ( + +
+
+ €{totalFunded} / €{totalTarget} funded + + {cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased + +
+
+
0 ? Math.round((totalFunded / totalTarget) * 100) : 0}%` }} /> +
+ +
+ {cartItems.map((item) => { + const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0 + return ( +
+
+ {item.item} + {item.status === 'Purchased' ? ( + ✓ Bought + ) : ( + €{item.funded}/€{item.target} + )} +
+
+
+
+
+ ) + })} +
+
+ + ) +} + +/* ─── Main Demo Content ─────────────────────────────────────── */ + +export default function DemoContent() { + const [trip, setTrip] = useState(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) => ({ + 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) => ({ + 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 ( +
+ {/* Nav */} + + + {/* Trip Header */} +
+
+

+ {trip?.title || 'Alpine Explorer 2026'} +

+

+ {trip?.description || 'Chamonix → Zermatt → Dolomites'} +

+
+ 📅 Jul 6–20, 2026 + 💶 ~€{trip?.budgetTotal?.toLocaleString() || '4,500'} budget + 🏔️ 3 countries + {liveFlags.trip && ( + + + Live data + + )} +
+ + {/* Member avatars */} +
+ {members.map((m) => ( +
+ {m.name[0]} +
+ ))} + {members.length} explorers +
+
+
+ + {/* rStack Intro */} +
+

+ Every trip is powered by the rStack — 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. +

+
+ + {/* Canvas Grid */} +
+
+ + + + + + +
+
+ + {/* Bottom CTA */} +
+
+

Plan Your Own Group Adventure

+

+ The rStack gives your group everything you need — routes, schedules, polls, shared + expenses, and gear lists — all connected in one trip canvas. +

+ + Start Planning + +
+
+ + {/* Footer */} + +
+ ) +} diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index e58dc91..d2ea799 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -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 = { - 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 ( -
-
-
- {icon} - {title} -
- - Open in {service} ↗ - -
-
{children}
-
- ) -} - -/* ─── Card: rMaps ───────────────────────────────────────────── */ - -function RMapsCard() { - return ( - -
- - {/* Mountain silhouettes */} - - - {/* Snow caps */} - - - - - {/* Route line */} - - - {/* Destination pins */} - {/* Chamonix */} - - - Chamonix - - - Jul 6–11 - - - {/* Zermatt */} - - - Zermatt - - - Jul 12–16 - - - {/* Dolomites */} - - - Dolomites - - - Jul 17–20 - - - {/* Activity icons along route */} - 🥾 - 🧗 - 🚵 - 🪂 - 🛶 - - - {/* Legend */} -
- - France - - - Switzerland - - - Italy - -
-
-
- ) -} - -/* ─── Card: rNotes ──────────────────────────────────────────── */ - -function RNotesCard() { - return ( - -
- {/* Packing list */} -
-

- Packing Checklist -

-
    - {packingList.map((item) => ( -
  • - - {item.checked && ( - - - - )} - - - {item.item} - -
  • - ))} -
-
- - {/* Trip rules */} -
-

- Trip Rules -

-
    - {tripRules.map((rule) => ( -
  1. {rule}
  2. - ))} -
-
-
-
- ) -} - -/* ─── 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 ( - -
-
-

July 2026

- 15 days • 3 countries -
- - {/* Day headers */} -
- {dayNames.map((d) => ( -
- {d} -
- ))} -
- - {/* Calendar grid */} -
- {/* Empty offset cells */} - {Array.from({ length: offset }).map((_, i) => ( -
- ))} - - {days.map((day) => { - const events = calendarEvents[day] - const isTrip = day >= 6 && day <= 20 - return ( -
- - {day} - - {events?.map((e) => ( -
- {e.label} -
- ))} -
- ) - })} -
- - {/* Activity legend */} -
- Travel - Hiking - Adventure - Biking - Extreme - Water - Transit -
-
- - ) -} - -/* ─── Card: rVote ───────────────────────────────────────────── */ - -function RVoteCard() { - return ( - -
- {polls.map((poll) => ( -
-

{poll.question}

-
- {poll.options.map((opt) => { - const pct = Math.round((opt.votes / poll.totalVotes) * 100) - return ( -
-
- {opt.label} - - {opt.votes} vote{opt.votes !== 1 ? 's' : ''} ({pct}%) - -
-
-
-
-
- ) - })} -
-

{poll.totalVotes} votes cast

-
- ))} -
- - ) -} - -/* ─── Card: rFunds ──────────────────────────────────────────── */ - -function RFundsCard() { - const totalSpent = expenses.reduce((s, e) => s + e.amount, 0) - - // Calculate balances per member - const balances: Record = {} - 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 ( - -
- {/* Total */} -
-

€{totalSpent.toLocaleString()}

-

Total group spending

-
- - {/* Recent transactions */} -
-

- Recent -

-
- {expenses.slice(0, 4).map((e) => ( -
-
-

{e.desc}

-

- {e.who} • split {e.split} ways -

-
- €{e.amount} -
- ))} -
-
- - {/* Balances */} -
-

- Balances -

-
- {members.map((m) => { - const bal = balances[m.name] || 0 - return ( -
- {m.name} - = 0 ? 'text-emerald-400' : 'text-rose-400'}> - {bal >= 0 ? '+' : ''}€{Math.round(bal)} - -
- ) - })} -
-
-
-
- ) -} - -/* ─── 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 ( - -
- {/* Summary bar */} -
- - €{totalFunded} / €{totalTarget} funded - - - {cartItems.filter((i) => i.status === 'Purchased').length}/{cartItems.length} purchased - -
-
-
-
- - {/* Items grid */} -
- {cartItems.map((item) => { - const pct = Math.round((item.funded / item.target) * 100) - return ( -
-
- {item.item} - {item.status === 'Purchased' ? ( - - ✓ Bought - - ) : ( - €{item.funded}/€{item.target} - )} -
-
-
-
-
- ) - })} -
-
- - ) -} - -/* ─── Page ──────────────────────────────────────────────────── */ - export default function DemoPage() { - return ( -
- {/* Nav */} - - - {/* Trip Header */} -
-
-

- Alpine Explorer 2026 -

-

- Chamonix → Zermatt → Dolomites -

-
- 📅 Jul 6–20, 2026 - 💶 ~€4,500 budget - 🏔️ 3 countries -
- - {/* Member avatars */} -
- {members.map((m) => ( -
- {m.name[0]} -
- ))} - 6 explorers -
-
-
- - {/* rStack Intro */} -
-

- Every trip is powered by the rStack — 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. -

-
- - {/* Canvas Grid */} -
-
- - - - - - -
-
- - {/* Bottom CTA */} -
-
-

Plan Your Own Group Adventure

-

- The rStack gives your group everything you need — routes, schedules, polls, shared - expenses, and gear lists — all connected in one trip canvas. -

- - Start Planning - -
-
- - {/* Footer */} - -
- ) + return }