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

1502 lines
83 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 _mapInstance: any = null;
private _mapMarkers: any[] = [];
private _mapPins: { lat: number; lng: number; label: string; color?: string }[] = [];
private _mapRoutes: any[] = [];
private _aiToolResults: { type: string; data: any; id: string }[] = [];
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?.();
this.destroyMap();
}
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() {
// Map container is destroyed by innerHTML — release the instance
if (this._mapInstance) { try { this._mapInstance.remove(); } catch {} this._mapInstance = null; this._mapMarkers = []; }
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; }
/* Workspace layout */
.ai-workspace { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.ai-map-container { height: 40%; min-height: 180px; border-bottom: 1px solid var(--rs-border, #333); position: relative; background: #0c1222; }
.ai-map-container .map-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--rs-text-muted); font-size: 13px; }
.ai-cards-scroll { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
/* Flight results card */
.ai-card--flights .flight-results { display: flex; flex-direction: column; gap: 4px; }
.flight-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; background: var(--rs-bg-surface-sunken, #1a1f2e); }
.flight-airline { font-weight: 600; width: 50px; flex-shrink: 0; }
.flight-times { flex: 1; font-size: 11px; }
.flight-duration { color: var(--rs-text-muted); font-size: 11px; white-space: nowrap; }
.flight-price { font-weight: 700; color: #14b8a6; white-space: nowrap; }
.flight-book { color: #0ea5e9; text-decoration: none; font-size: 11px; font-weight: 500; white-space: nowrap; }
.flight-book:hover { text-decoration: underline; }
.flight-search-link { display: block; text-align: center; margin-top: 8px; font-size: 11px; color: #0ea5e9; text-decoration: none; }
.flight-search-link:hover { text-decoration: underline; }
/* Route card */
.ai-card--route .route-info { font-size: 13px; color: var(--rs-text-secondary); display: flex; gap: 8px; align-items: center; }
/* Nearby POI card */
.ai-card--nearby .nearby-list { display: flex; flex-direction: column; gap: 4px; }
.nearby-item { display: flex; gap: 8px; align-items: center; font-size: 12px; padding: 4px 0; border-bottom: 1px solid var(--rs-border-strong, #222); }
.nearby-item:last-child { border-bottom: none; }
.nearby-name { flex: 1; font-weight: 500; }
.nearby-dist { color: var(--rs-text-muted); font-size: 11px; }
.nearby-type { font-size: 10px; color: var(--rs-text-muted); background: var(--rs-bg-surface-sunken, #1a1f2e); padding: 1px 6px; border-radius: 4px; }
</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.initOrUpdateMap();
} else {
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._aiToolResults = [];
this._mapPins = [];
this._mapRoutes = [];
this._aiLoading = false;
this.destroyMap();
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._aiToolResults = [];
this._mapPins = [];
this._mapRoutes = [];
this.destroyMap();
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">Tell me where you want to go and I'll search flights, find routes, discover nearby places, and build your full itinerary.</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) {
const labels = m.toolCalls.map((tc: any) => tc.label || tc.name.replace(/_/g, ' ')).slice(0, 4);
return `<div class="ai-msg tool-action">\u{1F6E0}\uFE0F ${labels.join(' \u00B7 ')}${m.toolCalls.length > 4 ? ` +${m.toolCalls.length - 4} more` : ''}</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-workspace">
<div class="ai-map-container" id="ai-map">
<div class="map-placeholder">${this._mapPins.length === 0 ? '\u{1F5FA}\uFE0F Destinations will appear on the map' : ''}</div>
</div>
<div class="ai-cards-scroll">
${this._aiToolResults.map(tr => this.renderToolResultCard(tr)).join('')}
${totalCount > 0 ? `
<div style="font-size:12px;font-weight:600;color:var(--rs-text-secondary);margin:4px 0">Generated Items (${acceptedCount}/${totalCount} accepted)</div>
${this._aiGeneratedItems.map(item => this.renderAiCard(item)).join('')}
` : (this._aiToolResults.length === 0 ? `<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>
</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 renderToolResultCard(tr: { type: string; data: any; id: string }): string {
const { type, data, id } = tr;
switch (type) {
case 'search_flights': {
const flights = data.flights || [];
const from = data._from || '';
const to = data._to || '';
return `
<div class="ai-card ai-card--flights" data-card-id="${id}">
<div class="ai-card-type">\u2708\uFE0F Flights${from && to ? `: ${this.esc(from)} \u2192 ${this.esc(to)}` : ''}</div>
${flights.length > 0 ? `<div class="flight-results">
${flights.map((f: any) => `
<div class="flight-row">
<span class="flight-airline">${this.esc(f.airline || '?')}</span>
<span class="flight-times">${f.departTime ? new Date(f.departTime).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '?'} \u2192 ${f.arriveTime ? new Date(f.arriveTime).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '?'}</span>
<span class="flight-duration">${f.duration || '?'} \u00B7 ${f.stops === 0 ? 'Direct' : f.stops + ' stop' + (f.stops > 1 ? 's' : '')}</span>
<span class="flight-price">${f.currency || '\u20AC'}${f.price}</span>
${f.bookingUrl ? `<a class="flight-book" href="${f.bookingUrl}" target="_blank">Book \u2192</a>` : ''}
</div>
`).join('')}
</div>` : `<div class="ai-card-detail">No flight results found</div>`}
${data.searchUrl ? `<a class="flight-search-link" href="${data.searchUrl}" target="_blank">See all results on Kiwi \u2192</a>` : ''}
${data.note ? `<div class="ai-card-detail" style="margin-top:4px;font-style:italic">${this.esc(data.note)}</div>` : ''}
</div>`;
}
case 'get_route': {
if (data.error) return `<div class="ai-card ai-card--route"><div class="ai-card-type">\u{1F6E3}\uFE0F Route</div><div class="ai-card-detail">Error: ${this.esc(data.error)}</div></div>`;
return `
<div class="ai-card ai-card--route" data-card-id="${id}">
<div class="ai-card-type">\u{1F6E3}\uFE0F Route${data._label ? `: ${this.esc(data._label)}` : ''}</div>
<div class="route-info">
<span>${data.distanceKm || '?'} km</span>
<span>\u00B7</span>
<span>${data.durationFormatted || '?'}</span>
<span>\u00B7</span>
<span>${data._mode || 'driving'}</span>
</div>
</div>`;
}
case 'geocode_place': {
if (data.error) return '';
return `
<div class="ai-card" data-card-id="${id}">
<div class="ai-card-type">\u{1F4CD} Location</div>
<div class="ai-card-title">${this.esc(data.displayName || data._query || 'Unknown')}</div>
<div class="ai-card-detail">${data.lat?.toFixed(4)}, ${data.lng?.toFixed(4)}</div>
</div>`;
}
case 'find_nearby': {
const results = data.results || [];
return `
<div class="ai-card ai-card--nearby" data-card-id="${id}">
<div class="ai-card-type">\u{1F4CD} ${this.esc((data.category || 'Places').charAt(0).toUpperCase() + (data.category || 'places').slice(1))}s nearby</div>
${results.length > 0 ? `<div class="nearby-list">
${results.map((r: any) => `
<div class="nearby-item">
<span class="nearby-name">${this.esc(r.name)}</span>
${r.distance != null ? `<span class="nearby-dist">${r.distance}m</span>` : ''}
<span class="nearby-type">${this.esc(r.type || data.category)}</span>
</div>
`).join('')}
</div>` : '<div class="ai-card-detail">No results found</div>'}
</div>`;
}
default:
return `<div class="ai-card"><div class="ai-card-type">${this.esc(type)}</div><div class="ai-card-detail">${this.esc(JSON.stringify(data).slice(0, 120))}</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[]; toolResults?: Record<string, 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/trips/ai-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, model: this._aiModel, systemPrompt }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
result = await res.json();
}
// Process tool calls — separate canvas items from live tool results
if (result.toolCalls?.length) {
const canvasTools = ['create_destination', 'create_itinerary', 'create_booking', 'create_budget', 'create_packing_list', 'create_map', 'create_note'];
for (const tc of result.toolCalls) {
if (canvasTools.includes(tc.name)) {
this._aiGeneratedItems.push({
type: tc.name,
props: tc.args || {},
accepted: false,
id: `ai-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
});
// Also add destination pins to map
if (tc.name === 'create_destination' && tc.args?.lat && tc.args?.lng) {
this._mapPins.push({ lat: tc.args.lat, lng: tc.args.lng, label: tc.args.destName || 'Destination', color: '#14b8a6' });
}
}
}
this._aiMessages.push({ role: 'assistant', content: '', toolCalls: result.toolCalls });
}
// Process tool results from server-executed trip tools
if (result.toolResults) {
for (const [key, data] of Object.entries(result.toolResults)) {
const toolName = key.replace(/_\d+$/, '');
const id = `tr-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
this._aiToolResults.push({ type: toolName, data, id });
this.processToolResultForMap(toolName, data);
}
}
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 processToolResultForMap(toolName: string, data: any) {
if (toolName === 'geocode_place' && data.lat && data.lng) {
this._mapPins.push({ lat: data.lat, lng: data.lng, label: data.displayName?.split(',')[0] || 'Pin', color: '#14b8a6' });
}
if (toolName === 'get_route' && data.geometry) {
this._mapRoutes.push(data.geometry);
}
if (toolName === 'find_nearby' && data.results) {
for (const r of data.results.slice(0, 4)) {
if (r.lat && r.lng) {
this._mapPins.push({ lat: r.lat, lng: r.lng, label: r.name, color: '#f59e0b' });
}
}
}
}
private buildAiSystemPrompt(): string {
let prompt = `You are a travel planning assistant in rTrips. Help users plan trips by understanding their needs first, then organizing the practical details.
CONVERSATION APPROACH:
1. First, understand the trip: Ask about destinations, dates, group size, budget, interests, and constraints
2. Then research: Use geocode_place for each destination to get coordinates, search_flights when dates are known, get_route for distances between places, find_nearby for restaurants/hotels/attractions
3. Then organize: Create structured destination cards, itineraries, budgets, and packing lists
4. Finally, refine: Ask if anything needs adjusting
AVAILABLE TOOLS:
Research tools (these execute live and return real data):
- search_flights: Search real flights between cities with prices and booking links
- get_route: Get driving/walking distance and duration between coordinates
- geocode_place: Look up coordinates for a city or place name
- find_nearby: Find restaurants, hotels, cafes, museums near a location
Planning tools (these create items the user can accept or discard):
- create_destination: Create a destination card with dates and coordinates
- create_itinerary: Create a day-by-day activity plan
- create_booking: Record a booking with confirmation details
- create_budget: Create a budget breakdown with estimated costs
- create_packing_list: Create a packing checklist
GUIDELINES:
- Always use geocode_place for destinations to get real coordinates for the map
- Use search_flights when the user confirms dates and origin city
- Use get_route to show distances between consecutive destinations
- Use find_nearby to suggest restaurants, hotels, or attractions at destinations
- Ask follow-up questions — don't generate everything at once
- Be specific with dates, prices, and logistics
- Use YYYY-MM-DD for dates, ISO currency codes (EUR, USD, JPY)`;
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[]; toolResults: Record<string, any> } {
const lower = text.toLowerCase();
const toolCalls: any[] = [];
const toolResults: Record<string, any> = {};
if (lower.includes('japan') || lower.includes('tokyo') || lower.includes('kyoto')) {
// Geocode results for map pins
toolResults['geocode_place_1'] = { lat: 35.6762, lng: 139.6503, displayName: 'Tokyo, Japan', _query: 'Tokyo' };
toolResults['geocode_place_2'] = { lat: 35.0116, lng: 135.7681, displayName: 'Kyoto, Japan', _query: 'Kyoto' };
// Flight results
toolCalls.push({ name: 'search_flights', args: { from: 'Berlin', to: 'Tokyo', departDate: '2026-06-01' }, label: 'Searching flights Berlin \u2192 Tokyo' });
toolResults['search_flights_3'] = {
_from: 'Berlin', _to: 'Tokyo',
flights: [
{ airline: 'ANA', duration: '12h 20m', stops: 1, price: 480, currency: '\u20AC', departTime: '2026-06-01T10:30:00Z', arriveTime: '2026-06-02T06:50:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo' },
{ airline: 'JAL', duration: '11h 45m', stops: 1, price: 520, currency: '\u20AC', departTime: '2026-06-01T13:15:00Z', arriveTime: '2026-06-02T09:00:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo' },
{ airline: 'Lufthansa+ANA', duration: '14h 10m', stops: 1, price: 445, currency: '\u20AC', departTime: '2026-06-01T08:00:00Z', arriveTime: '2026-06-02T06:10:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo' },
{ airline: 'Turkish', duration: '16h 30m', stops: 1, price: 390, currency: '\u20AC', departTime: '2026-06-01T18:20:00Z', arriveTime: '2026-06-02T14:50:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo' },
],
searchUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo/2026-06-01',
};
// Route between Tokyo and Kyoto
toolCalls.push({ name: 'get_route', args: { startLat: 35.6762, startLng: 139.6503, endLat: 35.0116, endLng: 135.7681 }, label: 'Getting route Tokyo \u2192 Kyoto' });
toolResults['get_route_4'] = {
_label: 'Tokyo \u2192 Kyoto', _mode: 'driving',
distanceKm: 476.3, durationFormatted: '5h 12m',
distance: 476300, duration: 18720,
geometry: { type: 'LineString', coordinates: [[139.6503, 35.6762], [138.5, 35.3], [137.0, 35.1], [136.0, 35.0], [135.7681, 35.0116]] },
};
// Nearby restaurants in Tokyo
toolCalls.push({ name: 'find_nearby', args: { lat: 35.6762, lng: 139.6503, category: 'restaurant' }, label: 'Finding restaurants near Tokyo' });
toolResults['find_nearby_5'] = {
category: 'restaurant',
results: [
{ name: 'Ichiran Ramen Shibuya', type: 'ramen', lat: 35.6598, lng: 139.7006, distance: 350 },
{ name: 'Sukiyabashi Jiro', type: 'sushi', lat: 35.6733, lng: 139.7638, distance: 780 },
{ name: 'Narisawa', type: 'fine dining', lat: 35.6656, lng: 139.7216, distance: 520 },
{ name: 'Tsuta Ramen', type: 'ramen', lat: 35.7189, lng: 139.7226, distance: 1200 },
],
};
// Canvas items
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' }, label: 'Created destination: Tokyo' },
{ 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' }, label: 'Created destination: Kyoto' },
{ 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' },
]) }, label: 'Created itinerary: Japan Adventure' },
{ 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' },
]) }, label: 'Created budget: $3,500' },
{ 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 },
]) }, label: 'Created packing list' },
);
return { content: "Here's a 5-day Japan plan! I found flights from \u20AC390 (Turkish with 1 stop) to \u20AC520 (JAL direct). Tokyo to Kyoto is 476km by car, but I'd recommend the Shinkansen bullet train (2h 15m). I also found some great restaurants near Shibuya. Accept the items you like and I can refine further!", toolCalls, toolResults };
}
if (lower.includes('europe') || lower.includes('paris') || lower.includes('rome') || lower.includes('barcelona')) {
toolResults['geocode_place_1'] = { lat: 48.8566, lng: 2.3522, displayName: 'Paris, France', _query: 'Paris' };
toolResults['geocode_place_2'] = { lat: 41.3874, lng: 2.1686, displayName: 'Barcelona, Spain', _query: 'Barcelona' };
toolCalls.push({ name: 'search_flights', args: { from: 'Paris', to: 'Barcelona', departDate: '2026-07-04' }, label: 'Searching flights Paris \u2192 Barcelona' });
toolResults['search_flights_3'] = {
_from: 'Paris', _to: 'Barcelona',
flights: [
{ airline: 'Vueling', duration: '1h 55m', stops: 0, price: 65, currency: '\u20AC', departTime: '2026-07-04T07:30:00Z', arriveTime: '2026-07-04T09:25:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/paris/barcelona' },
{ airline: 'Air France', duration: '2h 00m', stops: 0, price: 95, currency: '\u20AC', departTime: '2026-07-04T12:00:00Z', arriveTime: '2026-07-04T14:00:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/paris/barcelona' },
{ airline: 'Ryanair', duration: '2h 05m', stops: 0, price: 42, currency: '\u20AC', departTime: '2026-07-04T06:15:00Z', arriveTime: '2026-07-04T08:20:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/paris/barcelona' },
],
searchUrl: 'https://www.kiwi.com/en/search/results/paris/barcelona/2026-07-04',
};
toolCalls.push({ name: 'get_route', args: { startLat: 48.8566, startLng: 2.3522, endLat: 41.3874, endLng: 2.1686 }, label: 'Getting route Paris \u2192 Barcelona' });
toolResults['get_route_4'] = {
_label: 'Paris \u2192 Barcelona', _mode: 'driving',
distanceKm: 1035, durationFormatted: '9h 45m',
distance: 1035000, duration: 35100,
geometry: { type: 'LineString', coordinates: [[2.3522, 48.8566], [2.5, 46.0], [2.0, 43.0], [2.1686, 41.3874]] },
};
toolCalls.push(
{ name: 'create_destination', args: { destName: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522, arrivalDate: '2026-07-01', departureDate: '2026-07-04' }, label: 'Created destination: Paris' },
{ name: 'create_destination', args: { destName: 'Barcelona', country: 'Spain', lat: 41.3874, lng: 2.1686, arrivalDate: '2026-07-04', departureDate: '2026-07-07' }, label: 'Created destination: Barcelona' },
{ 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' },
]) }, label: 'Created budget: \u20AC4,000' },
);
return { content: "Here's a week in Europe \u2014 Paris and Barcelona! Flights between the two start at just \u20AC42 (Ryanair). It's 1,035km by car but only 2 hours by plane. I've set up destinations and a \u20AC4,000 budget.", toolCalls, toolResults };
}
if (lower.includes('beach') || lower.includes('tropical') || lower.includes('bali') || lower.includes('thailand')) {
toolResults['geocode_place_1'] = { lat: -8.3405, lng: 115.0920, displayName: 'Bali, Indonesia', _query: 'Bali' };
toolCalls.push({ name: 'find_nearby', args: { lat: -8.3405, lng: 115.0920, category: 'hotel' }, label: 'Finding hotels near Bali' });
toolResults['find_nearby_2'] = {
category: 'hotel',
results: [
{ name: 'Four Seasons Jimbaran', type: 'resort', lat: -8.7900, lng: 115.1600, distance: 2100 },
{ name: 'Hanging Gardens of Bali', type: 'boutique', lat: -8.4200, lng: 115.3800, distance: 3400 },
{ name: 'Alila Seminyak', type: 'beach hotel', lat: -8.6800, lng: 115.1500, distance: 1800 },
],
};
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' }, label: 'Created destination: Bali' },
{ 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' },
]) }, label: 'Created itinerary' },
{ 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' },
]) }, label: 'Created budget: $2,000' },
);
return { content: "Here's a relaxing week in Bali! I found some great hotels nearby including Four Seasons and Alila Seminyak. Budget is very affordable at $2,000 including flights, villa, and food.", toolCalls, toolResults };
}
// Generic fallback — conversational
return { content: "I'd love to help you plan a trip! To give you the best suggestions, could you tell me:\n\n1. **Where** do you want to go? (or what kind of trip \u2014 beach, city, adventure?)\n2. **When** are you thinking of traveling?\n3. **How many days** do you have?\n4. **Budget range** \u2014 backpacker, mid-range, or luxury?\n5. **Traveling solo** or with others?\n\nOnce I know more, I'll search for flights, find great spots, and build a full itinerary!", toolCalls: [], toolResults: {} };
}
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() {
this.destroyMap();
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();
}
}
/* ── MapLibre GL ── */
private async initOrUpdateMap() {
const container = this.shadow.getElementById('ai-map');
if (!container) return;
// Load MapLibre CSS + JS if not already loaded
const maplibreGL = (window as any).maplibregl;
if (!maplibreGL) {
// Load CSS into document head (for global elements)
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css';
document.head.appendChild(link);
// Load JS
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js';
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
}
// Inject MapLibre CSS into shadow DOM too (needed for popups, controls)
if (!this.shadow.querySelector('link[data-maplibre-css]')) {
const shadowLink = document.createElement('link');
shadowLink.rel = 'stylesheet';
shadowLink.href = 'https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css';
shadowLink.setAttribute('data-maplibre-css', '');
this.shadow.prepend(shadowLink);
}
const ml = (window as any).maplibregl;
if (!ml) return;
// Clear placeholder
const placeholder = container.querySelector('.map-placeholder');
if (placeholder) placeholder.remove();
if (!this._mapInstance) {
this._mapInstance = new ml.Map({
container,
style: {
version: 8,
sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '&copy; OpenStreetMap' } },
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
},
center: [0, 20],
zoom: 1.5,
});
// Dark filter
this._mapInstance.on('load', () => {
this._mapInstance?.getCanvas()?.style && (this._mapInstance.getCanvas().style.filter = 'brightness(0.7) contrast(1.1)');
this.updateMapPinsAndRoutes();
});
} else {
this.updateMapPinsAndRoutes();
}
}
private updateMapPinsAndRoutes() {
const ml = (window as any).maplibregl;
if (!this._mapInstance || !ml) return;
// Clear existing markers
for (const m of this._mapMarkers) m.remove();
this._mapMarkers = [];
// Add pins
for (const pin of this._mapPins) {
const el = document.createElement('div');
el.style.cssText = `width:12px;height:12px;background:${pin.color || '#14b8a6'};border:2px solid white;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,0.3);cursor:pointer;`;
const marker = new ml.Marker({ element: el })
.setLngLat([pin.lng, pin.lat])
.setPopup(new ml.Popup({ offset: 8, closeButton: false }).setText(pin.label))
.addTo(this._mapInstance);
this._mapMarkers.push(marker);
}
// Add route lines
for (let i = 0; i < this._mapRoutes.length; i++) {
const sourceId = `route-${i}`;
const layerId = `route-line-${i}`;
if (this._mapInstance.getSource(sourceId)) {
(this._mapInstance.getSource(sourceId) as any).setData({ type: 'Feature', geometry: this._mapRoutes[i], properties: {} });
} else {
this._mapInstance.addSource(sourceId, {
type: 'geojson',
data: { type: 'Feature', geometry: this._mapRoutes[i], properties: {} },
});
this._mapInstance.addLayer({
id: layerId, type: 'line', source: sourceId,
paint: { 'line-color': '#14b8a6', 'line-width': 3, 'line-opacity': 0.8, 'line-dasharray': [2, 1] },
});
}
}
// Fit bounds if we have pins
if (this._mapPins.length > 0) {
const bounds = new ml.LngLatBounds();
for (const pin of this._mapPins) bounds.extend([pin.lng, pin.lat]);
this._mapInstance.fitBounds(bounds, { padding: 40, maxZoom: 12, duration: 800 });
}
}
private destroyMap() {
if (this._mapInstance) {
try { this._mapInstance.remove(); } catch {}
this._mapInstance = null;
}
this._mapMarkers = [];
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-trips-planner", FolkTripsPlanner);