660 lines
38 KiB
TypeScript
660 lines
38 KiB
TypeScript
/**
|
|
* <folk-trips-planner> — 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<string, any> = {
|
|
"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<string, string> = {
|
|
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<string, { bg: string; color: string; label: string }> = {
|
|
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 = `
|
|
<style>
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
|
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
|
|
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
|
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
|
.rapp-nav__btn:hover { background: #0d9488; }
|
|
|
|
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
|
.trip-card {
|
|
background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 16px;
|
|
cursor: pointer; transition: border-color 0.2s, transform 0.15s;
|
|
}
|
|
.trip-card:hover { border-color: #555; transform: translateY(-1px); }
|
|
.trip-card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
.trip-name { font-size: 15px; font-weight: 600; flex: 1; }
|
|
.trip-status {
|
|
display: inline-block; font-size: 10px; font-weight: 600; padding: 3px 10px;
|
|
border-radius: 12px; text-transform: uppercase; letter-spacing: 0.04em;
|
|
}
|
|
.trip-chain { font-size: 12px; color: #94a3b8; margin-bottom: 8px; display: flex; align-items: center; gap: 4px; }
|
|
.trip-chain-icon { color: #64748b; }
|
|
.trip-dates { font-size: 12px; color: #64748b; margin-bottom: 6px; }
|
|
.trip-stats { display: flex; gap: 12px; font-size: 11px; color: #64748b; margin-bottom: 8px; }
|
|
.trip-stat { display: flex; align-items: center; gap: 3px; }
|
|
.trip-budget-bar { height: 4px; background: #2a2a3a; border-radius: 2px; overflow: hidden; }
|
|
.trip-budget-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
|
|
.trip-budget-label { display: flex; justify-content: space-between; font-size: 10px; color: #64748b; margin-top: 4px; }
|
|
|
|
.tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid #333; background: #16161e; color: #888; cursor: pointer; font-size: 12px; }
|
|
.tab:hover { border-color: #555; }
|
|
.tab.active { border-color: #14b8a6; color: #14b8a6; }
|
|
|
|
.section-title { font-size: 14px; font-weight: 600; margin: 16px 0 8px; color: #aaa; }
|
|
.item-row { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 10px 12px; margin-bottom: 6px; display: flex; gap: 10px; align-items: center; }
|
|
.item-title { font-size: 13px; font-weight: 500; flex: 1; }
|
|
.item-meta { font-size: 11px; color: #888; }
|
|
.badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: #2a2a3a; color: #aaa; }
|
|
|
|
.budget-bar { height: 8px; background: #2a2a3a; border-radius: 4px; margin: 8px 0; overflow: hidden; }
|
|
.budget-fill { height: 100%; background: #14b8a6; border-radius: 4px; transition: width 0.3s; }
|
|
|
|
/* Collaborators */
|
|
.collab-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
.collab {
|
|
display: flex; align-items: center; gap: 6px; padding: 6px 10px;
|
|
background: #16161e; border: 1px solid #222; border-radius: 8px; font-size: 12px;
|
|
}
|
|
.collab-avatar { font-size: 16px; }
|
|
.collab-name { font-weight: 500; color: #e2e8f0; }
|
|
.collab-role { font-size: 10px; color: #64748b; }
|
|
|
|
/* Itinerary with emoji categories */
|
|
.itin-row { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; margin-bottom: 4px; background: #1e1e2e; border: 1px solid #333; border-radius: 8px; }
|
|
.itin-emoji { font-size: 18px; flex-shrink: 0; width: 28px; text-align: center; }
|
|
.itin-body { flex: 1; min-width: 0; }
|
|
.itin-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
|
|
.itin-meta { font-size: 11px; color: #64748b; margin-top: 2px; }
|
|
|
|
/* Destination cards */
|
|
.dest-card { background: #1e1e2e; border: 1px solid #333; border-radius: 10px; padding: 14px; margin-bottom: 8px; display: flex; gap: 12px; align-items: center; }
|
|
.dest-pin { font-size: 24px; }
|
|
.dest-info { flex: 1; }
|
|
.dest-name { font-size: 14px; font-weight: 600; color: #e2e8f0; }
|
|
.dest-country { font-size: 12px; color: #94a3b8; }
|
|
.dest-date { font-size: 11px; color: #64748b; }
|
|
|
|
.packing-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #222; }
|
|
.packing-check { width: 16px; height: 16px; cursor: pointer; }
|
|
|
|
.empty { text-align: center; color: #666; padding: 40px; }
|
|
</style>
|
|
|
|
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
|
${this.view === "list" ? this.renderList() : this.renderDetail()}
|
|
`;
|
|
this.attachListeners();
|
|
}
|
|
|
|
private renderList(): string {
|
|
return `
|
|
<div class="rapp-nav">
|
|
<span class="rapp-nav__title">My Trips</span>
|
|
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
|
|
</div>
|
|
${this.trips.length > 0 ? `<div class="trip-grid">
|
|
${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 `
|
|
<div class="trip-card" data-trip="${t.id}">
|
|
<div class="trip-card-header">
|
|
<span class="trip-name">${this.esc(t.title)}</span>
|
|
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
|
</div>
|
|
${t.destinations_chain ? `<div class="trip-chain"><span class="trip-chain-icon">\u{1F4CD}</span> ${this.esc(t.destinations_chain)}</div>` : ""}
|
|
<div class="trip-dates">${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"}</div>
|
|
<div class="trip-stats">
|
|
<span class="trip-stat">\u{1F4CD} ${t.destination_count || 0} destinations</span>
|
|
${t.collaborator_count ? `<span class="trip-stat">\u{1F465} ${t.collaborator_count} people</span>` : ""}
|
|
</div>
|
|
${budget > 0 ? `
|
|
<div class="trip-budget-bar"><div class="trip-budget-fill" style="width:${pct}%;background:${budgetColor}"></div></div>
|
|
<div class="trip-budget-label"><span>$${spent.toFixed(0)} spent</span><span>$${budget.toFixed(0)} budget</span></div>
|
|
` : ""}
|
|
</div>
|
|
`;}).join("")}
|
|
</div>` : `<div class="empty">
|
|
<p style="font-size:16px;margin-bottom:8px">No trips yet</p>
|
|
<p style="font-size:13px">Start planning your next adventure</p>
|
|
</div>`}
|
|
`;
|
|
}
|
|
|
|
private renderDetail(): string {
|
|
if (!this.trip) return '<div class="empty">Loading...</div>';
|
|
const t = this.trip;
|
|
const st = this.getStatusStyle(t.status || "PLANNING");
|
|
return `
|
|
<div class="rapp-nav">
|
|
<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>
|
|
<span class="rapp-nav__title">${this.esc(t.title)}</span>
|
|
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
|
</div>
|
|
<div class="tabs">
|
|
${(["overview", "destinations", "itinerary", "bookings", "expenses", "packing"] as const).map(tab =>
|
|
`<button class="tab ${this.tab === tab ? "active" : ""}" data-tab="${tab}">${tab.charAt(0).toUpperCase() + tab.slice(1)}</button>`
|
|
).join("")}
|
|
</div>
|
|
${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 `
|
|
<div class="section-title">Trip Details</div>
|
|
<div class="item-row"><span class="item-title">${t.description || "No description"}</span></div>
|
|
${t.start_date ? `<div class="item-row"><span class="item-title">\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"}</span></div>` : ""}
|
|
${t.budget_total ? `
|
|
<div class="section-title">Budget</div>
|
|
<div class="item-row" style="flex-direction:column;align-items:stretch">
|
|
<div style="display:flex;justify-content:space-between;font-size:13px">
|
|
<span>$${spent.toFixed(0)} spent</span>
|
|
<span>$${parseFloat(t.budget_total).toFixed(0)} budget</span>
|
|
</div>
|
|
<div class="budget-bar"><div class="budget-fill" style="width:${pct}%;background:${budgetColor}"></div></div>
|
|
</div>
|
|
` : ""}
|
|
<div class="section-title">Summary</div>
|
|
<div class="item-row"><span class="item-meta">${(t.destinations || []).length} destinations \u00B7 ${(t.itinerary || []).length} activities \u00B7 ${(t.bookings || []).length} bookings \u00B7 ${(t.packing || []).length} packing items</span></div>
|
|
${(t.collaborators || []).length > 0 ? `
|
|
<div class="section-title">Team (${t.collaborators.length})</div>
|
|
<div class="collab-row">
|
|
${t.collaborators.map((c: any) => `
|
|
<div class="collab">
|
|
<span class="collab-avatar">${c.avatar || "\u{1F464}"}</span>
|
|
<div>
|
|
<span class="collab-name">${this.esc(c.name)}</span>
|
|
<span class="collab-role"> \u00B7 ${this.esc(c.role)}</span>
|
|
</div>
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
` : ""}
|
|
`;
|
|
}
|
|
case "destinations":
|
|
return (t.destinations || []).length > 0
|
|
? (t.destinations || []).map((d: any, i: number) => `
|
|
<div class="dest-card">
|
|
<span class="dest-pin">${i === 0 ? "\u{1F6EB}" : i === (t.destinations.length - 1) ? "\u{1F6EC}" : "\u{1F4CD}"}</span>
|
|
<div class="dest-info">
|
|
<div class="dest-name">${this.esc(d.name)}</div>
|
|
<div class="dest-country">${this.esc(d.country || "")}</div>
|
|
${d.arrival_date ? `<div class="dest-date">Arrives ${new Date(d.arrival_date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}</div>` : ""}
|
|
</div>
|
|
</div>
|
|
`).join("")
|
|
: '<div class="empty">No destinations added yet</div>';
|
|
case "itinerary": {
|
|
const items = (t.itinerary || []) as any[];
|
|
if (items.length === 0) return '<div class="empty">No itinerary items yet</div>';
|
|
// Group by date
|
|
const grouped: Record<string, any[]> = {};
|
|
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 += `<div class="section-title">${label}</div>`;
|
|
for (const item of dayItems) {
|
|
html += `<div class="itin-row">
|
|
<span class="itin-emoji">${this.getCategoryEmoji(item.category)}</span>
|
|
<div class="itin-body">
|
|
<div class="itin-title">${this.esc(item.title)}</div>
|
|
<div class="itin-meta">${item.start_time || ""} \u00B7 ${item.category || "ACTIVITY"}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
}
|
|
return html;
|
|
}
|
|
case "bookings":
|
|
return (t.bookings || []).length > 0
|
|
? (t.bookings || []).map((b: any) => `
|
|
<div class="item-row">
|
|
<span style="font-size:16px">${this.getCategoryEmoji(b.type)}</span>
|
|
<div style="flex:1">
|
|
<div class="item-title">${this.esc(b.provider || "Booking")}</div>
|
|
<div class="item-meta">${b.confirmation_number ? `#${b.confirmation_number}` : ""} ${b.cost ? `\u00B7 $${parseFloat(b.cost).toFixed(0)}` : ""}</div>
|
|
</div>
|
|
</div>
|
|
`).join("")
|
|
: '<div class="empty">No bookings yet</div>';
|
|
case "expenses":
|
|
return (t.expenses || []).length > 0
|
|
? (t.expenses || []).map((e: any) => `
|
|
<div class="item-row">
|
|
<span class="badge">${e.category || "OTHER"}</span>
|
|
<div style="flex:1">
|
|
<div class="item-title">${this.esc(e.description)}</div>
|
|
<div class="item-meta">${e.date ? new Date(e.date + "T00:00:00").toLocaleDateString() : ""}</div>
|
|
</div>
|
|
<span style="font-weight:600;color:#14b8a6">$${parseFloat(e.amount).toFixed(2)}</span>
|
|
</div>
|
|
`).join("")
|
|
: '<div class="empty">No expenses recorded yet</div>';
|
|
case "packing":
|
|
return (t.packing || []).length > 0
|
|
? `<div style="padding:0 4px">${(t.packing || []).map((p: any) => `
|
|
<div class="packing-item">
|
|
<input type="checkbox" class="packing-check" data-pack="${p.id}" ${p.packed ? "checked" : ""}>
|
|
<span class="item-title" style="${p.packed ? "text-decoration:line-through;opacity:0.5" : ""}">${this.esc(p.name)}</span>
|
|
<span class="badge">${p.category}</span>
|
|
${p.quantity > 1 ? `<span class="item-meta">x${p.quantity}</span>` : ""}
|
|
</div>
|
|
`).join("")}</div>`
|
|
: '<div class="empty">No packing items yet</div>';
|
|
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);
|