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:
parent
1a7e754425
commit
0d3d636751
|
|
@ -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`)"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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 (
|
||||
<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 6–20, 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 6–11
|
||||
</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 12–16
|
||||
</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 17–20
|
||||
</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 6–20, 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 />
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue