/** * — collaborative trip planning dashboard. * * Views: trip list → trip detail (tabs: overview, destinations, * itinerary, bookings, expenses, packing). * Demo: 4 trips with varied statuses and rich destination chains. */ class FolkTripsPlanner extends HTMLElement { private shadow: ShadowRoot; private space = ""; private view: "list" | "detail" = "list"; private trips: any[] = []; private trip: any = null; private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview"; private error = ""; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.loadTrips(); this.render(); } private loadDemoData() { this.trips = [ { id: "alpine-2026", title: "Alpine Explorer 2026", status: "PLANNING", start_date: "2026-07-06", end_date: "2026-07-20", budget_total: "4500", total_spent: "1203", destination_count: 3, destinations_chain: "Chamonix \u2192 Zermatt \u2192 Dolomites", description: "15-day adventure through the Alps \u2014 3 countries, 6 explorers, endless peaks.", collaborator_count: 6 }, { id: "berlin-dweb-2026", title: "DWeb Camp Berlin", status: "BOOKED", start_date: "2026-05-15", end_date: "2026-05-18", budget_total: "800", total_spent: "650", destination_count: 1, destinations_chain: "Berlin", description: "Decentralized Web camp at c-base \u2014 workshops, hackathons, and local-first demos.", collaborator_count: 12 }, { id: "portugal-retreat-2026", title: "Portugal Team Retreat", status: "IN_PROGRESS", start_date: "2026-02-20", end_date: "2026-02-28", budget_total: "3200", total_spent: "2870", destination_count: 2, destinations_chain: "Lisbon \u2192 Sintra", description: "Team offsite \u2014 strategy workshops, co-working from surf hostels, and exploring Sintra's castles.", collaborator_count: 8 }, { id: "japan-2025", title: "Japan Autumn 2025", status: "COMPLETED", start_date: "2025-10-10", end_date: "2025-10-24", budget_total: "5200", total_spent: "4980", destination_count: 4, destinations_chain: "Tokyo \u2192 Kyoto \u2192 Osaka \u2192 Hiroshima", description: "14-day autumn foliage tour \u2014 temples, street food, and bullet trains.", collaborator_count: 4 }, ]; this.render(); } private getDemoTripDetail(id: string): any { const trips: Record = { "alpine-2026": { id: "alpine-2026", title: "Alpine Explorer 2026", status: "PLANNING", start_date: "2026-07-06", end_date: "2026-07-20", budget_total: "4500", description: "15-day adventure through the Alps \u2014 Chamonix (France) \u2192 Zermatt (Switzerland) \u2192 Dolomites (Italy). 6 explorers, 3 countries, endless peaks.", destinations: [ { id: "d1", name: "Chamonix", country: "France", arrival_date: "2026-07-06", lat: 45.9237, lng: 6.8694 }, { id: "d2", name: "Zermatt", country: "Switzerland", arrival_date: "2026-07-12", lat: 46.0207, lng: 7.7491 }, { id: "d3", name: "Dolomites", country: "Italy", arrival_date: "2026-07-17", lat: 46.4102, lng: 11.8440 }, ], itinerary: [ { id: "i1", title: "Fly Geneva \u2192 Chamonix shuttle", category: "TRANSPORT", date: "2026-07-06", start_time: "10:00" }, { id: "i2", title: "Acclimatization hike \u2014 Lac Blanc", category: "ACTIVITY", date: "2026-07-07", start_time: "07:00" }, { id: "i3", title: "Via Ferrata \u2014 Aiguille du Midi", category: "ACTIVITY", date: "2026-07-08", start_time: "08:00" }, { id: "i4", title: "Rest day / Chamonix town", category: "FREE_TIME", date: "2026-07-09", start_time: "10:00" }, { id: "i5", title: "Group dinner \u2014 La Cabane", category: "MEAL", date: "2026-07-09", start_time: "19:00" }, { id: "i6", title: "Mont Blanc viewpoint hike", category: "ACTIVITY", date: "2026-07-10", start_time: "06:30" }, { id: "i7", title: "Farewell lunch in Chamonix", category: "MEAL", date: "2026-07-11", start_time: "12:00" }, { id: "i8", title: "Train to Zermatt via Glacier Express", category: "TRANSPORT", date: "2026-07-12", start_time: "08:00" }, { id: "i9", title: "Gornergrat sunrise hike", category: "ACTIVITY", date: "2026-07-13", start_time: "05:30" }, { id: "i10", title: "Matterhorn base camp trek", category: "ACTIVITY", date: "2026-07-14", start_time: "07:00" }, { id: "i11", title: "Paragliding over Zermatt", category: "ACTIVITY", date: "2026-07-15", start_time: "10:00" }, { id: "i12", title: "Fondue dinner \u2014 Chez Vrony", category: "MEAL", date: "2026-07-15", start_time: "19:30" }, { id: "i13", title: "Transfer to Dolomites", category: "TRANSPORT", date: "2026-07-16", start_time: "09:00" }, { id: "i14", title: "Tre Cime di Lavaredo loop", category: "ACTIVITY", date: "2026-07-17", start_time: "07:00" }, { id: "i15", title: "Lago di Braies kayaking", category: "ACTIVITY", date: "2026-07-18", start_time: "09:00" }, { id: "i16", title: "Cooking class in Bolzano", category: "ACTIVITY", date: "2026-07-19", start_time: "11:00" }, { id: "i17", title: "Free day \u2014 shopping & packing", category: "FREE_TIME", date: "2026-07-19", start_time: "14:00" }, { id: "i18", title: "Fly home from Innsbruck", category: "TRANSPORT", date: "2026-07-20", start_time: "12:00" }, ], bookings: [ { id: "bk1", type: "FLIGHT", provider: "easyJet \u2014 Geneva", confirmation_number: "EZY-20260706-ALP", cost: "890" }, { id: "bk2", type: "TRANSPORT", provider: "Glacier Express", confirmation_number: "GEX-445920", cost: "240" }, { id: "bk3", type: "ACCOMMODATION", provider: "Refuge du Lac Blanc", confirmation_number: "LB2026-234", cost: "320" }, { id: "bk4", type: "ACCOMMODATION", provider: "Hotel Matterhorn Focus, Zermatt", confirmation_number: "MF-88201", cost: "780" }, { id: "bk5", type: "ACCOMMODATION", provider: "Rifugio Locatelli, Dolomites", confirmation_number: "TRE2026-089", cost: "280" }, { id: "bk6", type: "ACTIVITY", provider: "Paragliding Zermatt (tandem x6)", confirmation_number: "PGZ-1120", cost: "1080" }, ], expenses: [ { id: "e1", category: "TRANSPORT", description: "Geneva \u2192 Chamonix shuttle (6 pax)", amount: "186", date: "2026-07-06" }, { id: "e2", category: "ACCOMMODATION", description: "Mountain hut reservations (3 nights)", amount: "420", date: "2026-07-07" }, { id: "e3", category: "ACTIVITY", description: "Via Ferrata gear rental (6 sets)", amount: "216", date: "2026-07-08" }, { id: "e4", category: "FOOD", description: "Groceries \u2014 Chamonix Carrefour", amount: "93", date: "2026-07-06" }, { id: "e5", category: "ACTIVITY", description: "Paragliding deposit (4 of 6 booked)", amount: "288", date: "2026-07-13" }, ], packing: [ { id: "pk1", name: "Hiking boots (broken in)", category: "FOOTWEAR", quantity: 1, packed: true }, { id: "pk2", name: "Rain jacket", category: "CLOTHING", quantity: 1, packed: true }, { id: "pk3", name: "Trekking poles", category: "GEAR", quantity: 1, packed: false }, { id: "pk4", name: "Headlamp + batteries", category: "GEAR", quantity: 1, packed: true }, { id: "pk5", name: "Sunscreen SPF 50", category: "PERSONAL", quantity: 1, packed: false }, { id: "pk6", name: "Water filter", category: "GEAR", quantity: 1, packed: false }, { id: "pk7", name: "First aid kit", category: "SAFETY", quantity: 1, packed: true }, { id: "pk8", name: "Passport + travel insurance", category: "DOCUMENTS", quantity: 1, packed: true }, ], collaborators: [ { name: "Alex", role: "organizer", avatar: "\u{1F9D1}\u200D\u{1F4BB}" }, { name: "Sam", role: "photographer", avatar: "\u{1F4F8}" }, { name: "Jordan", role: "logistics", avatar: "\u{1F5FA}\uFE0F" }, { name: "Riley", role: "navigator", avatar: "\u{1F9ED}" }, { name: "Casey", role: "gear lead", avatar: "\u{1F3D4}\uFE0F" }, { name: "Morgan", role: "safety", avatar: "\u26D1\uFE0F" }, ], }, "berlin-dweb-2026": { id: "berlin-dweb-2026", title: "DWeb Camp Berlin", status: "BOOKED", start_date: "2026-05-15", end_date: "2026-05-18", budget_total: "800", description: "Decentralized Web camp at c-base hackerspace \u2014 3 days of workshops, hackathons, local-first demos, and rStack presentations.", destinations: [ { id: "d1", name: "Berlin", country: "Germany", arrival_date: "2026-05-15", lat: 52.5130, lng: 13.4200 }, ], itinerary: [ { id: "i1", title: "Arrive & check in at c-base", category: "TRANSPORT", date: "2026-05-15", start_time: "14:00" }, { id: "i2", title: "Welcome session & icebreaker", category: "ACTIVITY", date: "2026-05-15", start_time: "16:00" }, { id: "i3", title: "Workshop: CRDT Fundamentals", category: "ACTIVITY", date: "2026-05-16", start_time: "09:00" }, { id: "i4", title: "Workshop: Automerge in Practice", category: "ACTIVITY", date: "2026-05-16", start_time: "14:00" }, { id: "i5", title: "Group dinner at Markthalle Neun", category: "MEAL", date: "2026-05-16", start_time: "19:30" }, { id: "i6", title: "Hackathon Day", category: "ACTIVITY", date: "2026-05-17", start_time: "09:00" }, { id: "i7", title: "Demo presentations", category: "ACTIVITY", date: "2026-05-17", start_time: "16:00" }, { id: "i8", title: "Wrap-up & departure", category: "FREE_TIME", date: "2026-05-18", start_time: "10:00" }, ], bookings: [ { id: "bk1", type: "ACCOMMODATION", provider: "Hostel One80\u00B0, Berlin", confirmation_number: "H180-5520", cost: "240" }, { id: "bk2", type: "ACTIVITY", provider: "c-base space rental", confirmation_number: "CB-DWEB-2026", cost: "350" }, ], expenses: [ { id: "e1", category: "FOOD", description: "Group catering (3 days)", amount: "180", date: "2026-05-15" }, { id: "e2", category: "ACTIVITY", description: "Workshop materials & supplies", amount: "120", date: "2026-05-14" }, { id: "e3", category: "TRANSPORT", description: "BVG group day passes x3", amount: "48", date: "2026-05-15" }, { id: "e4", category: "FOOD", description: "Markthalle Neun dinner (12 pax)", amount: "302", date: "2026-05-16" }, ], packing: [ { id: "pk1", name: "Laptop + charger", category: "ELECTRONICS", quantity: 1, packed: true }, { id: "pk2", name: "USB-C hub", category: "ELECTRONICS", quantity: 1, packed: true }, { id: "pk3", name: "Notebook & pen", category: "SUPPLIES", quantity: 1, packed: true }, { id: "pk4", name: "Water bottle", category: "PERSONAL", quantity: 1, packed: false }, ], collaborators: [ { name: "Alex", role: "organizer", avatar: "\u{1F9D1}\u200D\u{1F4BB}" }, { name: "Mia", role: "facilitator", avatar: "\u{1F3A4}" }, { name: "Leo", role: "AV setup", avatar: "\u{1F3AC}" }, ], }, "portugal-retreat-2026": { id: "portugal-retreat-2026", title: "Portugal Team Retreat", status: "IN_PROGRESS", start_date: "2026-02-20", end_date: "2026-02-28", budget_total: "3200", description: "Team offsite in Portugal \u2014 strategy workshops, co-working from surf hostels, exploring Sintra's fairy-tale castles, and Lisbon street food adventures.", destinations: [ { id: "d1", name: "Lisbon", country: "Portugal", arrival_date: "2026-02-20", lat: 38.7223, lng: -9.1393 }, { id: "d2", name: "Sintra", country: "Portugal", arrival_date: "2026-02-25", lat: 38.7979, lng: -9.3906 }, ], itinerary: [ { id: "i1", title: "Arrive Lisbon \u2014 check in", category: "TRANSPORT", date: "2026-02-20", start_time: "14:00" }, { id: "i2", title: "Welcome dinner \u2014 Time Out Market", category: "MEAL", date: "2026-02-20", start_time: "19:30" }, { id: "i3", title: "Strategy workshop: Q2 roadmap", category: "ACTIVITY", date: "2026-02-21", start_time: "09:00" }, { id: "i4", title: "Co-working at Second Home", category: "ACTIVITY", date: "2026-02-22", start_time: "09:30" }, { id: "i5", title: "Alfama walking tour", category: "ACTIVITY", date: "2026-02-22", start_time: "15:00" }, { id: "i6", title: "Surf lesson in Cascais", category: "ACTIVITY", date: "2026-02-23", start_time: "09:00" }, { id: "i7", title: "Team retrospective", category: "ACTIVITY", date: "2026-02-24", start_time: "10:00" }, { id: "i8", title: "Train to Sintra", category: "TRANSPORT", date: "2026-02-25", start_time: "09:00" }, { id: "i9", title: "Pena Palace visit", category: "ACTIVITY", date: "2026-02-25", start_time: "11:00" }, { id: "i10", title: "Quinta da Regaleira", category: "ACTIVITY", date: "2026-02-26", start_time: "10:00" }, { id: "i11", title: "Free day \u2014 explore Sintra", category: "FREE_TIME", date: "2026-02-27", start_time: "09:00" }, { id: "i12", title: "Return to Lisbon & fly home", category: "TRANSPORT", date: "2026-02-28", start_time: "08:00" }, ], bookings: [ { id: "bk1", type: "FLIGHT", provider: "TAP Air Portugal BER\u2192LIS", confirmation_number: "TAP-2026-8834", cost: "420" }, { id: "bk2", type: "ACCOMMODATION", provider: "Lisbon Surf House (5 nights)", confirmation_number: "LSH-FEB26", cost: "850" }, { id: "bk3", type: "ACCOMMODATION", provider: "Sintra B&B (3 nights)", confirmation_number: "SBB-8820", cost: "540" }, { id: "bk4", type: "ACTIVITY", provider: "Cascais Surf School (8 pax)", confirmation_number: "CSS-G8-0223", cost: "480" }, { id: "bk5", type: "TRANSPORT", provider: "CP Train Lisbon\u2192Sintra return", confirmation_number: "CP-ROUND-26", cost: "64" }, ], expenses: [ { id: "e1", category: "FOOD", description: "Time Out Market welcome dinner", amount: "310", date: "2026-02-20" }, { id: "e2", category: "TRANSPORT", description: "Airport shuttle + Uber rides", amount: "95", date: "2026-02-20" }, { id: "e3", category: "ACTIVITY", description: "Second Home day passes (8 pax)", amount: "240", date: "2026-02-22" }, { id: "e4", category: "FOOD", description: "Groceries & coffee (week)", amount: "175", date: "2026-02-21" }, { id: "e5", category: "ACTIVITY", description: "Pena Palace tickets (8 pax)", amount: "112", date: "2026-02-25" }, { id: "e6", category: "ACTIVITY", description: "Quinta da Regaleira tickets", amount: "96", date: "2026-02-26" }, { id: "e7", category: "FOOD", description: "Farewell dinner \u2014 Ramiro", amount: "280", date: "2026-02-27" }, ], packing: [ { id: "pk1", name: "Laptop + charger", category: "ELECTRONICS", quantity: 1, packed: true }, { id: "pk2", name: "Swimsuit & towel", category: "CLOTHING", quantity: 1, packed: true }, { id: "pk3", name: "Sunscreen SPF 50", category: "PERSONAL", quantity: 1, packed: true }, { id: "pk4", name: "Light jacket", category: "CLOTHING", quantity: 1, packed: true }, { id: "pk5", name: "Comfortable walking shoes", category: "FOOTWEAR", quantity: 1, packed: true }, { id: "pk6", name: "Passport", category: "DOCUMENTS", quantity: 1, packed: true }, ], collaborators: [ { name: "Alex", role: "organizer", avatar: "\u{1F9D1}\u200D\u{1F4BB}" }, { name: "Sam", role: "co-lead", avatar: "\u{1F91D}" }, { name: "Jordan", role: "logistics", avatar: "\u{1F5FA}\uFE0F" }, { name: "Mia", role: "facilitator", avatar: "\u{1F3A4}" }, { name: "Leo", role: "content", avatar: "\u{1F4DD}" }, { name: "Kai", role: "surf guide", avatar: "\u{1F3C4}" }, { name: "Riley", role: "finance", avatar: "\u{1F4B0}" }, { name: "Morgan", role: "wellbeing", avatar: "\u{1F9D8}" }, ], }, "japan-2025": { id: "japan-2025", title: "Japan Autumn 2025", status: "COMPLETED", start_date: "2025-10-10", end_date: "2025-10-24", budget_total: "5200", description: "14-day autumn foliage tour through Japan \u2014 Tokyo \u2192 Kyoto \u2192 Osaka \u2192 Hiroshima. Temples, street food, bullet trains, and unforgettable views.", destinations: [ { id: "d1", name: "Tokyo", country: "Japan", arrival_date: "2025-10-10", lat: 35.6762, lng: 139.6503 }, { id: "d2", name: "Kyoto", country: "Japan", arrival_date: "2025-10-14", lat: 35.0116, lng: 135.7681 }, { id: "d3", name: "Osaka", country: "Japan", arrival_date: "2025-10-19", lat: 34.6937, lng: 135.5023 }, { id: "d4", name: "Hiroshima", country: "Japan", arrival_date: "2025-10-22", lat: 34.3853, lng: 132.4553 }, ], itinerary: [ { id: "i1", title: "Arrive Tokyo \u2014 Narita Express", category: "TRANSPORT", date: "2025-10-10", start_time: "15:00" }, { id: "i2", title: "Shibuya & Harajuku exploration", category: "ACTIVITY", date: "2025-10-11", start_time: "10:00" }, { id: "i3", title: "Tsukiji Outer Market & teamLab", category: "ACTIVITY", date: "2025-10-12", start_time: "08:00" }, { id: "i4", title: "Day trip to Nikko", category: "ACTIVITY", date: "2025-10-13", start_time: "07:30" }, { id: "i5", title: "Shinkansen to Kyoto", category: "TRANSPORT", date: "2025-10-14", start_time: "10:00" }, { id: "i6", title: "Fushimi Inari & Kiyomizu-dera", category: "ACTIVITY", date: "2025-10-15", start_time: "07:00" }, { id: "i7", title: "Arashiyama bamboo grove", category: "ACTIVITY", date: "2025-10-16", start_time: "08:00" }, { id: "i8", title: "Tea ceremony in Gion", category: "ACTIVITY", date: "2025-10-17", start_time: "14:00" }, { id: "i9", title: "Nara day trip (deer park)", category: "ACTIVITY", date: "2025-10-18", start_time: "09:00" }, { id: "i10", title: "Train to Osaka", category: "TRANSPORT", date: "2025-10-19", start_time: "10:00" }, { id: "i11", title: "Dotonbori street food crawl", category: "MEAL", date: "2025-10-19", start_time: "17:00" }, { id: "i12", title: "Osaka Castle & Shinsekai", category: "ACTIVITY", date: "2025-10-20", start_time: "09:00" }, { id: "i13", title: "Cooking class \u2014 takoyaki", category: "ACTIVITY", date: "2025-10-21", start_time: "11:00" }, { id: "i14", title: "Shinkansen to Hiroshima", category: "TRANSPORT", date: "2025-10-22", start_time: "09:00" }, { id: "i15", title: "Peace Memorial & Museum", category: "ACTIVITY", date: "2025-10-22", start_time: "13:00" }, { id: "i16", title: "Miyajima Island (floating torii)", category: "ACTIVITY", date: "2025-10-23", start_time: "08:00" }, { id: "i17", title: "Return to Tokyo & fly home", category: "TRANSPORT", date: "2025-10-24", start_time: "08:00" }, ], bookings: [ { id: "bk1", type: "FLIGHT", provider: "ANA Berlin \u2192 Tokyo Narita", confirmation_number: "ANA-NH204-1010", cost: "1240" }, { id: "bk2", type: "TRANSPORT", provider: "JR Pass 14-day (4 pax)", confirmation_number: "JRP-4X14-2025", cost: "1680" }, { id: "bk3", type: "ACCOMMODATION", provider: "Hotel Gracery Shinjuku (4 nights)", confirmation_number: "GRA-TKY-2210", cost: "560" }, { id: "bk4", type: "ACCOMMODATION", provider: "Kyoto Machiya Guesthouse (5 nights)", confirmation_number: "KMG-KYO-1415", cost: "480" }, { id: "bk5", type: "ACCOMMODATION", provider: "Hostel 64 Osaka (3 nights)", confirmation_number: "H64-OSA-1920", cost: "195" }, { id: "bk6", type: "ACCOMMODATION", provider: "Hiroshima Hana Hostel (2 nights)", confirmation_number: "HHH-HIR-2224", cost: "120" }, ], expenses: [ { id: "e1", category: "TRANSPORT", description: "Narita Express x4", amount: "120", date: "2025-10-10" }, { id: "e2", category: "FOOD", description: "Ramen, sushi, yakitori (week 1)", amount: "380", date: "2025-10-11" }, { id: "e3", category: "ACTIVITY", description: "teamLab Borderless tickets x4", amount: "96", date: "2025-10-12" }, { id: "e4", category: "ACTIVITY", description: "Kyoto tea ceremony (4 pax)", amount: "160", date: "2025-10-17" }, { id: "e5", category: "FOOD", description: "Dotonbori street food evening", amount: "85", date: "2025-10-19" }, { id: "e6", category: "ACTIVITY", description: "Takoyaki cooking class (4 pax)", amount: "120", date: "2025-10-21" }, { id: "e7", category: "FOOD", description: "Okonomiyaki farewell dinner", amount: "68", date: "2025-10-23" }, { id: "e8", category: "SHOPPING", description: "Souvenirs & gifts", amount: "245", date: "2025-10-23" }, ], packing: [ { id: "pk1", name: "Comfortable walking shoes", category: "FOOTWEAR", quantity: 1, packed: true }, { id: "pk2", name: "Rain jacket (light)", category: "CLOTHING", quantity: 1, packed: true }, { id: "pk3", name: "JR Pass printout", category: "DOCUMENTS", quantity: 1, packed: true }, { id: "pk4", name: "Portable WiFi hotspot", category: "ELECTRONICS", quantity: 1, packed: true }, { id: "pk5", name: "Camera + extra battery", category: "ELECTRONICS", quantity: 1, packed: true }, { id: "pk6", name: "Passport + visa", category: "DOCUMENTS", quantity: 1, packed: true }, { id: "pk7", name: "Power adapter (Type A)", category: "ELECTRONICS", quantity: 1, packed: true }, { id: "pk8", name: "Day backpack", category: "GEAR", quantity: 1, packed: true }, ], collaborators: [ { name: "Alex", role: "organizer", avatar: "\u{1F9D1}\u200D\u{1F4BB}" }, { name: "Sam", role: "photographer", avatar: "\u{1F4F8}" }, { name: "Mia", role: "food guide", avatar: "\u{1F363}" }, { name: "Leo", role: "translator", avatar: "\u{1F5E3}\uFE0F" }, ], }, }; return trips[id] || trips["alpine-2026"]; } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/trips/); return match ? `/${match[1]}/trips` : ""; } private async loadTrips() { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/trips`); if (res.ok) this.trips = await res.json(); } catch { this.trips = []; } this.render(); } private async loadTrip(id: string) { if (this.space === "demo") { this.trip = this.getDemoTripDetail(id); this.render(); return; } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/trips/${id}`); if (res.ok) this.trip = await res.json(); } catch { this.error = "Failed to load trip"; } this.render(); } private async createTrip() { const title = prompt("Trip name:"); if (!title?.trim()) return; try { const base = this.getApiBase(); const res = await fetch(`${base}/api/trips`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: title.trim() }), }); if (res.ok) this.loadTrips(); } catch { this.error = "Failed to create trip"; this.render(); } } private getCategoryEmoji(cat: string): string { const map: Record = { TRANSPORT: "\u{1F68C}", ACTIVITY: "\u{1F3AF}", MEAL: "\u{1F37D}\uFE0F", FREE_TIME: "\u2615", FLIGHT: "\u2708\uFE0F", ACCOMMODATION: "\u{1F3E8}", }; return map[cat] || "\u{1F4CC}"; } private getStatusStyle(status: string): { bg: string; color: string; label: string } { const map: Record = { PLANNING: { bg: "#1e3a5f", color: "#60a5fa", label: "Planning" }, BOOKED: { bg: "#1a3b2e", color: "#34d399", label: "Booked" }, IN_PROGRESS: { bg: "#3b2e11", color: "#fbbf24", label: "In Progress" }, COMPLETED: { bg: "#1a3b1a", color: "#22c55e", label: "Completed" }, CANCELLED: { bg: "#3b1a1a", color: "#f87171", label: "Cancelled" }, }; return map[status] || map.PLANNING; } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.view === "list" ? this.renderList() : this.renderDetail()} `; this.attachListeners(); } private renderList(): string { return `
My Trips
${this.trips.length > 0 ? `
${this.trips.map(t => { const st = this.getStatusStyle(t.status || "PLANNING"); const spent = parseFloat(t.total_spent || 0); const budget = parseFloat(t.budget_total || 0); const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0; const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6"; return `
${this.esc(t.title)} ${st.label}
${t.destinations_chain ? `
\u{1F4CD} ${this.esc(t.destinations_chain)}
` : ""}
${t.start_date ? new Date(t.start_date + "T00:00:00").toLocaleDateString("default", { month: "short", day: "numeric" }) : ""} \u2013 ${t.end_date ? new Date(t.end_date + "T00:00:00").toLocaleDateString("default", { month: "short", day: "numeric", year: "numeric" }) : "open"}
\u{1F4CD} ${t.destination_count || 0} destinations ${t.collaborator_count ? `\u{1F465} ${t.collaborator_count} people` : ""}
${budget > 0 ? `
$${spent.toFixed(0)} spent$${budget.toFixed(0)} budget
` : ""}
`;}).join("")}
` : `

No trips yet

Start planning your next adventure

`} `; } private renderDetail(): string { if (!this.trip) return '
Loading...
'; const t = this.trip; const st = this.getStatusStyle(t.status || "PLANNING"); return `
${this.esc(t.title)} ${st.label}
${(["overview", "destinations", "itinerary", "bookings", "expenses", "packing"] as const).map(tab => `` ).join("")}
${this.renderTab()} `; } private renderTab(): string { const t = this.trip; switch (this.tab) { case "overview": { const spent = (t.expenses || []).reduce((s: number, e: any) => s + parseFloat(e.amount || 0), 0); const pct = t.budget_total ? Math.min(100, (spent / parseFloat(t.budget_total)) * 100) : 0; const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6"; return `
Trip Details
${t.description || "No description"}
${t.start_date ? `
\u{1F4C5} ${new Date(t.start_date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "long", day: "numeric", year: "numeric" })} \u2014 ${t.end_date ? new Date(t.end_date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "long", day: "numeric" }) : "open"}
` : ""} ${t.budget_total ? `
Budget
$${spent.toFixed(0)} spent $${parseFloat(t.budget_total).toFixed(0)} budget
` : ""}
Summary
${(t.destinations || []).length} destinations \u00B7 ${(t.itinerary || []).length} activities \u00B7 ${(t.bookings || []).length} bookings \u00B7 ${(t.packing || []).length} packing items
${(t.collaborators || []).length > 0 ? `
Team (${t.collaborators.length})
${t.collaborators.map((c: any) => `
${c.avatar || "\u{1F464}"}
${this.esc(c.name)} \u00B7 ${this.esc(c.role)}
`).join("")}
` : ""} `; } case "destinations": return (t.destinations || []).length > 0 ? (t.destinations || []).map((d: any, i: number) => `
${i === 0 ? "\u{1F6EB}" : i === (t.destinations.length - 1) ? "\u{1F6EC}" : "\u{1F4CD}"}
${this.esc(d.name)}
${this.esc(d.country || "")}
${d.arrival_date ? `
Arrives ${new Date(d.arrival_date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}
` : ""}
`).join("") : '
No destinations added yet
'; case "itinerary": { const items = (t.itinerary || []) as any[]; if (items.length === 0) return '
No itinerary items yet
'; // Group by date const grouped: Record = {}; for (const item of items) { const d = item.date || "undated"; if (!grouped[d]) grouped[d] = []; grouped[d].push(item); } let html = ""; for (const [date, dayItems] of Object.entries(grouped)) { const label = date !== "undated" ? new Date(date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" }) : "Undated"; html += `
${label}
`; for (const item of dayItems) { html += `
${this.getCategoryEmoji(item.category)}
${this.esc(item.title)}
${item.start_time || ""} \u00B7 ${item.category || "ACTIVITY"}
`; } } return html; } case "bookings": return (t.bookings || []).length > 0 ? (t.bookings || []).map((b: any) => `
${this.getCategoryEmoji(b.type)}
${this.esc(b.provider || "Booking")}
${b.confirmation_number ? `#${b.confirmation_number}` : ""} ${b.cost ? `\u00B7 $${parseFloat(b.cost).toFixed(0)}` : ""}
`).join("") : '
No bookings yet
'; case "expenses": return (t.expenses || []).length > 0 ? (t.expenses || []).map((e: any) => `
${e.category || "OTHER"}
${this.esc(e.description)}
${e.date ? new Date(e.date + "T00:00:00").toLocaleDateString() : ""}
$${parseFloat(e.amount).toFixed(2)}
`).join("") : '
No expenses recorded yet
'; case "packing": return (t.packing || []).length > 0 ? `
${(t.packing || []).map((p: any) => `
${this.esc(p.name)} ${p.category} ${p.quantity > 1 ? `x${p.quantity}` : ""}
`).join("")}
` : '
No packing items yet
'; default: return ""; } } private attachListeners() { this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip()); this.shadow.querySelectorAll("[data-trip]").forEach(el => { el.addEventListener("click", () => { this.view = "detail"; this.tab = "overview"; this.loadTrip((el as HTMLElement).dataset.trip!); }); }); this.shadow.querySelectorAll("[data-back]").forEach(el => { el.addEventListener("click", () => { this.view = "list"; if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); } }); }); this.shadow.querySelectorAll("[data-tab]").forEach(el => { el.addEventListener("click", () => { this.tab = (el as HTMLElement).dataset.tab as any; this.render(); }); }); this.shadow.querySelectorAll("[data-pack]").forEach(el => { el.addEventListener("change", async () => { const checkbox = el as HTMLInputElement; try { const base = this.getApiBase(); await fetch(`${base}/api/packing/${checkbox.dataset.pack}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ packed: checkbox.checked }), }); } catch {} }); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-trips-planner", FolkTripsPlanner);