rspace-online/modules/rtrips/components/folk-trips-planner.ts

1139 lines
64 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.
*/
import { tripSchema, type TripDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: "list" | "detail" | "ai-planner" = "list";
private trips: any[] = [];
private trip: any = null;
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
private error = "";
private _offlineUnsubs: (() => void)[] = [];
private _stopPresence: (() => void) | null = null;
private _history = new ViewHistory<"list" | "detail" | "ai-planner">("list");
private _aiMessages: { role: string; content: string; toolCalls?: any[] }[] = [];
private _aiGeneratedItems: { type: string; props: Record<string, any>; accepted: boolean; id: string }[] = [];
private _aiLoading = false;
private _aiModel = 'gemini-flash';
private _aiTripContext: any = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#create-trip', title: "Plan a Trip", message: "Start planning a new trip — add destinations, itinerary, and budget.", advanceOnClick: false },
{ target: '.trip-card', title: "Trip Cards", message: "View trip details, status, budget progress, and collaborators.", advanceOnClick: true },
{ target: '.tab', title: "Detail Tabs", message: "Explore destinations, itinerary, bookings, expenses, and packing lists.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkTripsPlanner.TOUR_STEPS,
"rtrips_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); }
else {
this.subscribeOffline();
this.loadTrips();
this.render();
}
if (!localStorage.getItem("rtrips_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtrips', context: this.trip?.name || 'Trips' }));
}
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
this._stopPresence?.();
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docs = await runtime.subscribeModule('trips', 'trips', tripSchema);
if (docs.size > 0 && this.trips.length === 0) {
const fromDocs: any[] = [];
for (const [docId, doc] of docs) {
const d = doc as TripDoc;
if (!d?.trip) continue;
fromDocs.push({
id: d.trip.id, title: d.trip.title, status: d.trip.status,
start_date: d.trip.startDate, end_date: d.trip.endDate,
budget_total: d.trip.budgetTotal, description: d.trip.description,
destination_count: Object.keys(d.destinations || {}).length,
});
this._offlineUnsubs.push(runtime.onChange(docId, () => {}));
}
if (fromDocs.length > 0) {
this.trips = fromDocs;
this.render();
}
}
} catch { /* runtime unavailable */ }
}
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(/^(\/[^/]+)?\/rtrips/);
return match ? match[0] : "";
}
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: flex; flex-direction: column; height: calc(100vh - 112px); font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); overflow: hidden; }
* { box-sizing: border-box; }
.rtrips-shell { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
.rtrips-scroll { flex: 1; overflow-y: auto; min-height: 0; padding-bottom: 8px; }
.rapp-nav { flex-shrink: 0; display: flex; gap: 8px; margin-bottom: 12px; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border-subtle); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; }
.rapp-nav__back:hover { color: var(--rs-text-primary); border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); 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; }
.rapp-nav__btn--ai { background: linear-gradient(135deg, #0ea5e9, #6366f1); }
.rapp-nav__btn--ai:hover { opacity: 0.9; background: linear-gradient(135deg, #0ea5e9, #6366f1); }
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.trip-card {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 12px; padding: 16px;
cursor: pointer; transition: border-color 0.2s, transform 0.15s;
}
.trip-card:hover { border-color: var(--rs-bg-surface-raised); 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: var(--rs-text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 4px; }
.trip-chain-icon { color: var(--rs-text-muted); }
.trip-dates { font-size: 12px; color: var(--rs-text-muted); margin-bottom: 6px; }
.trip-stats { display: flex; gap: 12px; font-size: 11px; color: var(--rs-text-muted); margin-bottom: 8px; }
.trip-stat { display: flex; align-items: center; gap: 3px; }
.trip-budget-bar { height: 4px; background: var(--rs-bg-surface-sunken); 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: var(--rs-text-muted); margin-top: 4px; }
.tabs { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; flex-shrink: 0; }
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-muted); cursor: pointer; font-size: 12px; }
.tab:hover { border-color: var(--rs-bg-surface-raised); }
.tab.active { border-color: #14b8a6; color: #14b8a6; }
.section-title { font-size: 14px; font-weight: 600; margin: 16px 0 8px; color: var(--rs-text-secondary); }
.item-row { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); 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: var(--rs-text-muted); }
.badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: var(--rs-bg-surface-sunken); color: var(--rs-text-secondary); }
.budget-bar { height: 8px; background: var(--rs-bg-surface-sunken); 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: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-strong); border-radius: 8px; font-size: 12px;
}
.collab-avatar { font-size: 16px; }
.collab-name { font-weight: 500; color: var(--rs-text-primary); }
.collab-role { font-size: 10px; color: var(--rs-text-muted); }
/* Itinerary with emoji categories */
.itin-row { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; margin-bottom: 4px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); 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: var(--rs-text-primary); }
.itin-meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 2px; }
/* Destination cards */
.dest-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); 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: var(--rs-text-primary); }
.dest-country { font-size: 12px; color: var(--rs-text-secondary); }
.dest-date { font-size: 11px; color: var(--rs-text-muted); }
.packing-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--rs-border-strong); }
.packing-check { width: 16px; height: 16px; cursor: pointer; }
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
/* AI Planner */
.ai-planner { display: flex; flex: 1; min-height: 0; gap: 0; }
.ai-chat { width: 40%; min-width: 280px; display: flex; flex-direction: column; border-right: 1px solid var(--rs-border, #e5e7eb); }
.ai-chat-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; }
.ai-msg { padding: 8px 10px; border-radius: 8px; margin-bottom: 6px; font-size: 13px; line-height: 1.4; max-width: 85%; white-space: pre-wrap; word-break: break-word; }
.ai-msg.user { background: #14b8a6; color: white; align-self: flex-end; margin-left: auto; }
.ai-msg.assistant { background: var(--rs-bg-surface-raised, #f1f5f9); align-self: flex-start; }
.ai-msg.tool-action { background: #dbeafe; color: #1e40af; font-size: 11px; text-align: center; max-width: 100%; align-self: center; padding: 4px 12px; border-radius: 12px; }
.ai-chat-input-row { display: flex; gap: 6px; padding: 12px; border-top: 1px solid var(--rs-border, #e5e7eb); align-items: flex-end; }
.ai-chat-input { flex: 1; padding: 8px 10px; border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; font-size: 13px; resize: none; min-height: 36px; max-height: 100px; background: var(--rs-bg-surface, #fff); color: var(--rs-text-primary); font-family: inherit; }
.ai-chat-input:focus { outline: none; border-color: #14b8a6; }
.ai-model-select { padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border, #e5e7eb); font-size: 11px; background: var(--rs-bg-surface, #fff); color: var(--rs-text-primary); }
.ai-cards { flex: 1; overflow-y: auto; padding: 16px; }
.ai-cards-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.ai-cards-header h3 { margin: 0; font-size: 14px; font-weight: 600; }
.ai-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.ai-card { border-radius: 10px; border: 1px solid var(--rs-border-subtle, #e5e7eb); padding: 12px; background: var(--rs-bg-surface, #fff); transition: border-color 0.2s; }
.ai-card.accepted { border-color: #14b8a6; background: color-mix(in srgb, #14b8a6 5%, transparent); }
.ai-card-type { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--rs-text-muted, #999); margin-bottom: 4px; }
.ai-card-title { font-weight: 600; font-size: 14px; margin-bottom: 6px; }
.ai-card-detail { font-size: 12px; color: var(--rs-text-secondary, #666); line-height: 1.4; }
.ai-card-actions { display: flex; gap: 6px; margin-top: 8px; }
.ai-card-btn { padding: 4px 12px; border-radius: 6px; border: none; font-size: 11px; cursor: pointer; font-weight: 500; }
.ai-card-btn.accept { background: #14b8a6; color: white; }
.ai-card-btn.accept:hover { background: #0d9488; }
.ai-card-btn.discard { background: var(--rs-bg-surface-raised, #f1f5f9); color: var(--rs-text-secondary, #666); }
.ai-card-btn.discard:hover { background: var(--rs-bg-surface-sunken, #e2e8f0); }
.ai-card-btn.accepted-badge { background: #14b8a6; color: white; cursor: default; opacity: 0.7; }
.ai-send-btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: white; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.ai-send-btn:hover { background: #0d9488; }
.ai-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-clear-btn { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--rs-border, #e5e7eb); background: transparent; color: var(--rs-text-muted); font-size: 11px; cursor: pointer; }
.ai-loading { display: inline-block; width: 16px; height: 16px; border: 2px solid #14b8a640; border-top: 2px solid #14b8a6; border-radius: 50%; animation: ai-spin 0.8s linear infinite; }
@keyframes ai-spin { to { transform: rotate(360deg); } }
.ai-empty { text-align: center; color: var(--rs-text-muted); padding: 40px 20px; }
.ai-empty p { margin: 4px 0; }
</style>
${this.error ? `<div style="color:var(--rs-error);text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
<div class="rtrips-shell">
${this.view === "ai-planner" ? this.renderAiPlanner() : this.view === "list" ? this.renderList() : this.renderDetail()}
</div>
`;
this.attachListeners();
if (this.view !== 'ai-planner') this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
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>
<button class="rapp-nav__btn rapp-nav__btn--ai" id="btn-plan-ai">✨ Plan with AI</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
<div class="rtrips-scroll">
${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}" data-collab-id="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>`}
</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">
${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""}
<span class="rapp-nav__title">${this.esc(t.title)}</span>
<button class="rapp-nav__btn rapp-nav__btn--ai" id="btn-plan-ai">✨ Plan with AI</button>
<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>
<div class="rtrips-scroll">${this.renderTab()}</div>
`;
}
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" data-collab-id="dest:${d.id || i}">
<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.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
this.shadow.getElementById("btn-plan-ai")?.addEventListener("click", () => {
this._aiTripContext = this.trip || null;
this._aiMessages = [];
this._aiGeneratedItems = [];
this._aiLoading = false;
this._history.push(this.view as any);
this.view = 'ai-planner';
this.render();
});
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
el.addEventListener("click", () => {
this._history.push("list");
this.view = "detail";
this.tab = "overview";
this._history.push("detail", { tripId: (el as HTMLElement).dataset.trip });
this.loadTrip((el as HTMLElement).dataset.trip!);
});
});
this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => this.goBack());
});
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 {}
});
});
// AI Planner listeners
const aiInput = this.shadow.getElementById('ai-input') as HTMLTextAreaElement | null;
const aiSend = this.shadow.getElementById('ai-send');
if (aiInput && aiSend) {
const doSend = () => {
const text = aiInput.value;
if (text.trim()) this.sendAiMessage(text);
};
aiSend.addEventListener('click', doSend);
aiInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
});
// Auto-resize textarea
aiInput.addEventListener('input', () => {
aiInput.style.height = 'auto';
aiInput.style.height = Math.min(aiInput.scrollHeight, 100) + 'px';
});
// Auto-focus
setTimeout(() => aiInput.focus(), 50);
}
this.shadow.getElementById('ai-model')?.addEventListener('change', (e) => {
this._aiModel = (e.target as HTMLSelectElement).value;
});
this.shadow.getElementById('ai-clear')?.addEventListener('click', () => {
this._aiMessages = [];
this._aiGeneratedItems = [];
this.render();
});
this.shadow.getElementById('btn-export-canvas')?.addEventListener('click', () => this.exportToCanvas());
this.shadow.querySelectorAll('[data-accept]').forEach(el => {
el.addEventListener('click', () => this.acceptItem((el as HTMLElement).dataset.accept!));
});
this.shadow.querySelectorAll('[data-discard]').forEach(el => {
el.addEventListener('click', () => this.discardItem((el as HTMLElement).dataset.discard!));
});
}
/* ── AI Planner View ── */
private renderAiPlanner(): string {
const acceptedCount = this._aiGeneratedItems.filter(i => i.accepted).length;
const totalCount = this._aiGeneratedItems.length;
const tripName = this._aiTripContext?.title || '';
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="prev">\u2190 Back</button>
<span class="rapp-nav__title">AI Trip Planner${tripName ? ` \u2014 ${this.esc(tripName)}` : ''}</span>
${totalCount > 0 && acceptedCount > 0 ? `<button class="rapp-nav__btn" id="btn-export-canvas">Export to Canvas (${acceptedCount})</button>` : ''}
</div>
<div class="ai-planner">
<div class="ai-chat">
<div class="ai-chat-messages" id="ai-messages">
${this._aiMessages.length === 0 ? `
<div class="ai-empty">
<p style="font-size:20px">✨</p>
<p style="font-size:14px;font-weight:500">Plan your trip with AI</p>
<p style="font-size:12px">Describe your trip and I'll generate destinations, itineraries, budgets, and more.</p>
${tripName ? `<p style="font-size:11px;margin-top:8px;color:var(--rs-text-secondary)">Context: ${this.esc(tripName)}</p>` : ''}
</div>
` : this._aiMessages.map(m => {
if (m.toolCalls?.length) {
return `<div class="ai-msg tool-action">\u{1F6E0}\uFE0F Created ${m.toolCalls.length} item${m.toolCalls.length > 1 ? 's' : ''}</div>`;
}
return `<div class="ai-msg ${m.role}">${this.esc(m.content)}</div>`;
}).join('')}
${this._aiLoading ? '<div class="ai-msg assistant"><span class="ai-loading"></span></div>' : ''}
</div>
<div class="ai-chat-input-row">
<textarea class="ai-chat-input" id="ai-input" placeholder="Plan 5 days in Japan..." rows="1"></textarea>
<select class="ai-model-select" id="ai-model">
<option value="gemini-flash"${this._aiModel === 'gemini-flash' ? ' selected' : ''}>Gemini Flash</option>
<option value="gemini-pro"${this._aiModel === 'gemini-pro' ? ' selected' : ''}>Gemini Pro</option>
</select>
<button class="ai-send-btn" id="ai-send"${this._aiLoading ? ' disabled' : ''}>Send</button>
${this._aiMessages.length > 0 ? '<button class="ai-clear-btn" id="ai-clear">Clear</button>' : ''}
</div>
</div>
<div class="ai-cards">
<div class="ai-cards-header">
<h3>Generated Items${totalCount > 0 ? ` (${acceptedCount}/${totalCount} accepted)` : ''}</h3>
</div>
${totalCount > 0 ? `<div class="ai-cards-grid">
${this._aiGeneratedItems.map(item => this.renderAiCard(item)).join('')}
</div>` : `<div class="ai-empty">
<p style="font-size:28px">\u{1F5FA}\uFE0F</p>
<p style="font-size:13px">AI-generated trip items will appear here</p>
</div>`}
</div>
</div>
`;
}
private renderAiCard(item: { type: string; props: Record<string, any>; accepted: boolean; id: string }): string {
const { type, props, accepted, id } = item;
let emoji = '\u{1F4CC}';
let title = type;
let detail = '';
switch (type) {
case 'create_destination':
emoji = '\u{1F4CD}';
title = `${props.destName || 'Destination'}${props.country ? ', ' + props.country : ''}`;
detail = [props.arrivalDate, props.departureDate].filter(Boolean).join(' \u2192 ') || (props.notes || '');
break;
case 'create_itinerary': {
emoji = '\u{1F4C5}';
title = props.tripTitle || 'Itinerary';
let items: any[] = [];
try { items = typeof props.itemsJson === 'string' ? JSON.parse(props.itemsJson) : (props.items || []); } catch {}
detail = `${items.length} activities`;
break;
}
case 'create_booking': {
const typeEmojis: Record<string, string> = { FLIGHT: '\u2708\uFE0F', HOTEL: '\u{1F3E8}', CAR_RENTAL: '\u{1F697}', TRAIN: '\u{1F682}', BUS: '\u{1F68C}', FERRY: '\u26F4\uFE0F', ACTIVITY: '\u{1F3AF}', RESTAURANT: '\u{1F37D}\uFE0F' };
emoji = typeEmojis[props.bookingType] || '\u{1F4CB}';
title = `${props.bookingType || 'Booking'} \u2014 ${props.provider || ''}`;
detail = [props.cost != null ? `${props.currency || 'USD'} ${props.cost}` : '', props.startDate || ''].filter(Boolean).join(' \u00B7 ');
break;
}
case 'create_budget': {
emoji = '\u{1F4B0}';
title = `Budget: ${props.currency || 'USD'} ${props.budgetTotal}`;
let expenses: any[] = [];
try { expenses = typeof props.expensesJson === 'string' ? JSON.parse(props.expensesJson) : (props.expenses || []); } catch {}
detail = `${expenses.length} expense${expenses.length !== 1 ? 's' : ''}`;
break;
}
case 'create_packing_list': {
emoji = '\u{1F392}';
title = 'Packing List';
let pItems: any[] = [];
try { pItems = typeof props.itemsJson === 'string' ? JSON.parse(props.itemsJson) : (props.items || []); } catch {}
detail = `${pItems.length} items`;
break;
}
case 'create_map':
emoji = '\u{1F5FA}\uFE0F';
title = props.location_name || 'Map';
detail = props.latitude != null ? `(${props.latitude.toFixed(2)}, ${props.longitude.toFixed(2)})` : '';
break;
case 'create_note':
emoji = '\u{1F4DD}';
title = props.title || 'Note';
detail = (props.content || '').slice(0, 80) + ((props.content || '').length > 80 ? '...' : '');
break;
default:
title = type.replace('create_', '').replace(/_/g, ' ');
detail = JSON.stringify(props).slice(0, 80);
}
const typePretty = type.replace('create_', '').replace(/_/g, ' ');
return `
<div class="ai-card${accepted ? ' accepted' : ''}" data-card-id="${id}">
<div class="ai-card-type">${emoji} ${this.esc(typePretty)}</div>
<div class="ai-card-title">${this.esc(title)}</div>
${detail ? `<div class="ai-card-detail">${this.esc(detail)}</div>` : ''}
<div class="ai-card-actions">
${accepted
? '<button class="ai-card-btn accepted-badge">\u2713 Accepted</button>'
: `<button class="ai-card-btn accept" data-accept="${id}">Accept</button>
<button class="ai-card-btn discard" data-discard="${id}">Discard</button>`
}
</div>
</div>
`;
}
private async sendAiMessage(text: string) {
if (!text.trim() || this._aiLoading) return;
this._aiMessages.push({ role: 'user', content: text.trim() });
this._aiLoading = true;
this.render();
// Scroll chat to bottom
const msgBox = this.shadow.getElementById('ai-messages');
if (msgBox) msgBox.scrollTop = msgBox.scrollHeight;
try {
let result: { content: string; toolCalls?: any[] };
if (this.space === 'demo') {
result = this.mockAiResponse(text);
} else {
const systemPrompt = this.buildAiSystemPrompt();
const messages = this._aiMessages
.filter(m => m.role === 'user' || (m.role === 'assistant' && m.content))
.map(m => ({ role: m.role, content: m.content }));
const res = await fetch('/api/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, model: this._aiModel, useTools: true, systemPrompt }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
result = await res.json();
}
// Process tool calls into generated items
if (result.toolCalls?.length) {
for (const tc of result.toolCalls) {
this._aiGeneratedItems.push({
type: tc.name,
props: tc.args || {},
accepted: false,
id: `ai-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
});
}
this._aiMessages.push({ role: 'assistant', content: '', toolCalls: result.toolCalls });
}
if (result.content) {
this._aiMessages.push({ role: 'assistant', content: result.content });
}
} catch (err: any) {
this._aiMessages.push({ role: 'assistant', content: `Error: ${err.message || 'Failed to get response'}` });
}
this._aiLoading = false;
this.render();
const msgBox2 = this.shadow.getElementById('ai-messages');
if (msgBox2) msgBox2.scrollTop = msgBox2.scrollHeight;
}
private buildAiSystemPrompt(): string {
let prompt = `You are a travel planning AI in rTrips. Help plan trips by creating structured items using tools.
When the user describes a trip, proactively create:
- Destination cards for each city/place (with coordinates and dates)
- An itinerary with activities by date
- Booking suggestions for flights, hotels, transport
- A budget tracker with estimated costs
- A packing list tailored to the destination
Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Be specific and practical.`;
if (this._aiTripContext) {
const t = this._aiTripContext;
prompt += `\n\nCurrent trip context:
- Title: ${t.title || 'Untitled'}
- Status: ${t.status || 'PLANNING'}
- Dates: ${t.start_date || '?'} to ${t.end_date || '?'}
- Budget: $${t.budget_total || '0'}`;
if (t.destinations?.length) {
prompt += `\n- Existing destinations: ${t.destinations.map((d: any) => d.name).join(', ')}`;
}
if (t.description) {
prompt += `\n- Description: ${t.description}`;
}
}
return prompt;
}
private mockAiResponse(text: string): { content: string; toolCalls: any[] } {
const lower = text.toLowerCase();
const toolCalls: any[] = [];
if (lower.includes('japan') || lower.includes('tokyo') || lower.includes('kyoto')) {
toolCalls.push(
{ name: 'create_destination', args: { destName: 'Tokyo', country: 'Japan', lat: 35.6762, lng: 139.6503, arrivalDate: '2026-06-01', departureDate: '2026-06-04', notes: 'Explore Shibuya, Akihabara, and Tsukiji Market' } },
{ name: 'create_destination', args: { destName: 'Kyoto', country: 'Japan', lat: 35.0116, lng: 135.7681, arrivalDate: '2026-06-04', departureDate: '2026-06-06', notes: 'Visit Fushimi Inari, Arashiyama bamboo grove' } },
{ name: 'create_itinerary', args: { tripTitle: 'Japan Adventure', itemsJson: JSON.stringify([
{ id: 'it1', title: 'Arrive Tokyo \u2014 Narita Express', date: '2026-06-01', startTime: '15:00', category: 'TRANSPORT' },
{ id: 'it2', title: 'Shibuya & Harajuku exploration', date: '2026-06-02', startTime: '10:00', category: 'ACTIVITY' },
{ id: 'it3', title: 'Tsukiji Market & teamLab', date: '2026-06-03', startTime: '08:00', category: 'ACTIVITY' },
{ id: 'it4', title: 'Shinkansen to Kyoto', date: '2026-06-04', startTime: '10:00', category: 'TRANSPORT' },
{ id: 'it5', title: 'Fushimi Inari & Kiyomizu-dera', date: '2026-06-05', startTime: '07:00', category: 'ACTIVITY' },
]) } },
{ name: 'create_budget', args: { budgetTotal: 3500, currency: 'USD', expensesJson: JSON.stringify([
{ id: 'e1', category: 'TRANSPORT', description: 'Round-trip flights', amount: 1200, date: '2026-06-01' },
{ id: 'e2', category: 'ACCOMMODATION', description: 'Hotels (5 nights)', amount: 800, date: '2026-06-01' },
{ id: 'e3', category: 'TRANSPORT', description: 'JR Pass (7 day)', amount: 280, date: '2026-06-01' },
{ id: 'e4', category: 'FOOD', description: 'Food & dining', amount: 500, date: '2026-06-01' },
{ id: 'e5', category: 'ACTIVITY', description: 'Activities & entrance fees', amount: 300, date: '2026-06-01' },
]) } },
{ name: 'create_packing_list', args: { itemsJson: JSON.stringify([
{ id: 'p1', name: 'Comfortable walking shoes', category: 'FOOTWEAR', quantity: 1, packed: false },
{ id: 'p2', name: 'Rain jacket (light)', category: 'CLOTHING', quantity: 1, packed: false },
{ id: 'p3', name: 'Portable WiFi hotspot', category: 'ELECTRONICS', quantity: 1, packed: false },
{ id: 'p4', name: 'Power adapter (Type A)', category: 'ELECTRONICS', quantity: 1, packed: false },
{ id: 'p5', name: 'Passport + visa', category: 'DOCUMENTS', quantity: 1, packed: false },
]) } },
);
return { content: "Here's a 5-day Japan plan covering Tokyo and Kyoto! I've created destination cards, a day-by-day itinerary, an estimated budget of $3,500, and a packing list. You can accept items you like and discard the rest.", toolCalls };
}
if (lower.includes('europe') || lower.includes('paris') || lower.includes('rome') || lower.includes('barcelona')) {
toolCalls.push(
{ name: 'create_destination', args: { destName: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522, arrivalDate: '2026-07-01', departureDate: '2026-07-04' } },
{ name: 'create_destination', args: { destName: 'Barcelona', country: 'Spain', lat: 41.3874, lng: 2.1686, arrivalDate: '2026-07-04', departureDate: '2026-07-07' } },
{ name: 'create_booking', args: { bookingType: 'FLIGHT', provider: 'Air France CDG\u2192BCN', cost: 180, currency: 'EUR', startDate: '2026-07-04' } },
{ name: 'create_budget', args: { budgetTotal: 4000, currency: 'EUR', expensesJson: JSON.stringify([
{ id: 'e1', category: 'TRANSPORT', description: 'Flights', amount: 900, date: '2026-07-01' },
{ id: 'e2', category: 'ACCOMMODATION', description: 'Hotels (6 nights)', amount: 1200, date: '2026-07-01' },
{ id: 'e3', category: 'FOOD', description: 'Dining', amount: 700, date: '2026-07-01' },
]) } },
);
return { content: "Here's a week in Europe \u2014 Paris and Barcelona! I've created destination cards, a connecting flight, and a budget estimate.", toolCalls };
}
if (lower.includes('beach') || lower.includes('tropical') || lower.includes('bali') || lower.includes('thailand')) {
toolCalls.push(
{ name: 'create_destination', args: { destName: 'Bali', country: 'Indonesia', lat: -8.3405, lng: 115.0920, arrivalDate: '2026-08-10', departureDate: '2026-08-17', notes: 'Ubud rice terraces, Seminyak beach, temple tours' } },
{ name: 'create_itinerary', args: { tripTitle: 'Bali Beach Retreat', itemsJson: JSON.stringify([
{ id: 'it1', title: 'Arrive Ngurah Rai Airport', date: '2026-08-10', startTime: '14:00', category: 'TRANSPORT' },
{ id: 'it2', title: 'Ubud rice terrace trek', date: '2026-08-11', startTime: '08:00', category: 'ACTIVITY' },
{ id: 'it3', title: 'Temple tour \u2014 Tirta Empul', date: '2026-08-12', startTime: '09:00', category: 'ACTIVITY' },
{ id: 'it4', title: 'Surf lesson at Seminyak', date: '2026-08-13', startTime: '07:00', category: 'ACTIVITY' },
{ id: 'it5', title: 'Beach day & spa', date: '2026-08-14', startTime: '10:00', category: 'FREE_TIME' },
]) } },
{ name: 'create_budget', args: { budgetTotal: 2000, currency: 'USD', expensesJson: JSON.stringify([
{ id: 'e1', category: 'TRANSPORT', description: 'Flights', amount: 800, date: '2026-08-10' },
{ id: 'e2', category: 'ACCOMMODATION', description: 'Villa (7 nights)', amount: 600, date: '2026-08-10' },
{ id: 'e3', category: 'FOOD', description: 'Food & drinks', amount: 300, date: '2026-08-10' },
]) } },
);
return { content: "Here's a relaxing week in Bali! Includes rice terraces, temples, surfing, and plenty of beach time. Budget is very affordable at $2,000.", toolCalls };
}
// Generic fallback
toolCalls.push(
{ name: 'create_note', args: { title: 'Trip Planning Notes', content: `Planning ideas based on: "${text}"\n\n- Research destinations\n- Check flight prices\n- Look for accommodation\n- Plan day-by-day itinerary\n- Set budget` } },
);
return { content: "I've created a planning note to get started. Tell me more about where you'd like to go, your travel dates, budget, and interests \u2014 and I'll generate detailed destination cards, itineraries, and budget estimates!", toolCalls };
}
private acceptItem(id: string) {
const item = this._aiGeneratedItems.find(i => i.id === id);
if (item) { item.accepted = true; this.render(); }
}
private discardItem(id: string) {
this._aiGeneratedItems = this._aiGeneratedItems.filter(i => i.id !== id);
this.render();
}
private exportToCanvas() {
const accepted = this._aiGeneratedItems.filter(i => i.accepted);
if (accepted.length === 0) return;
sessionStorage.setItem('rtrips-canvas-export', JSON.stringify(accepted.map(i => ({ type: i.type, props: i.props }))));
const nav = (window as any).__rspaceNavUrl;
window.location.href = (nav ? nav(this.space, 'rspace') : `/${this.space}/rspace`) + '#trip-import';
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (this.view === "list") {
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
} else {
this.render();
}
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-trips-planner", FolkTripsPlanner);