diff --git a/modules/rtrips/components/folk-trips-planner.ts b/modules/rtrips/components/folk-trips-planner.ts index 363821d..8c3862b 100644 --- a/modules/rtrips/components/folk-trips-planner.ts +++ b/modules/rtrips/components/folk-trips-planner.ts @@ -15,14 +15,19 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; class FolkTripsPlanner extends HTMLElement { private shadow: ShadowRoot; private space = ""; - private view: "list" | "detail" = "list"; + 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">("list"); + private _history = new ViewHistory<"list" | "detail" | "ai-planner">("list"); + private _aiMessages: { role: string; content: string; toolCalls?: any[] }[] = []; + private _aiGeneratedItems: { type: string; props: Record; 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 }, @@ -492,13 +497,50 @@ class FolkTripsPlanner extends HTMLElement { .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; height: calc(100vh - 60px); 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; } ${this.error ? `
${this.esc(this.error)}
` : ""} - ${this.view === "list" ? this.renderList() : this.renderDetail()} + ${this.view === "ai-planner" ? this.renderAiPlanner() : this.view === "list" ? this.renderList() : this.renderDetail()} `; this.attachListeners(); - this._tour.renderOverlay(); + if (this.view !== 'ai-planner') this._tour.renderOverlay(); } startTour() { @@ -687,7 +729,13 @@ class FolkTripsPlanner extends HTMLElement { 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", () => { - window.location.href = `/${this.space}/rspace#trip-planner`; + 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 => { @@ -721,6 +769,345 @@ class FolkTripsPlanner extends HTMLElement { } 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 ` +
+ + AI Trip Planner${tripName ? ` \u2014 ${this.esc(tripName)}` : ''} + ${totalCount > 0 && acceptedCount > 0 ? `` : ''} +
+
+
+
+ ${this._aiMessages.length === 0 ? ` +
+

+

Plan your trip with AI

+

Describe your trip and I'll generate destinations, itineraries, budgets, and more.

+ ${tripName ? `

Context: ${this.esc(tripName)}

` : ''} +
+ ` : this._aiMessages.map(m => { + if (m.toolCalls?.length) { + return `
\u{1F6E0}\uFE0F Created ${m.toolCalls.length} item${m.toolCalls.length > 1 ? 's' : ''}
`; + } + return `
${this.esc(m.content)}
`; + }).join('')} + ${this._aiLoading ? '
' : ''} +
+
+ + + + ${this._aiMessages.length > 0 ? '' : ''} +
+
+
+
+

Generated Items${totalCount > 0 ? ` (${acceptedCount}/${totalCount} accepted)` : ''}

+
+ ${totalCount > 0 ? `
+ ${this._aiGeneratedItems.map(item => this.renderAiCard(item)).join('')} +
` : `
+

\u{1F5FA}\uFE0F

+

AI-generated trip items will appear here

+
`} +
+
+ `; + } + + private renderAiCard(item: { type: string; props: Record; 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 = { 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 ` +
+
${emoji} ${this.esc(typePretty)}
+
${this.esc(title)}
+ ${detail ? `
${this.esc(detail)}
` : ''} +
+ ${accepted + ? '' + : ` + ` + } +
+
+ `; + } + + 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 })))); + window.location.href = `/${this.space}/rspace#trip-import`; } private goBack() { diff --git a/website/canvas.html b/website/canvas.html index 08d746a..dc40c4e 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -3441,6 +3441,48 @@ if (hash.startsWith("#pin-")) { const pinId = hash.slice(5); setTimeout(() => pinManager.openPinById(pinId), 500); + } else if (hash === "#trip-import") { + history.replaceState(null, "", location.pathname + location.search); + setTimeout(() => { + const raw = sessionStorage.getItem('rtrips-canvas-export'); + sessionStorage.removeItem('rtrips-canvas-export'); + if (!raw) return; + try { + const items = JSON.parse(raw); + // Map tool names to tagNames + build props (mirrors canvas-tools.ts) + const TOOL_MAP = { + create_map: { tag: 'folk-map', build: (a) => ({ center: [a.longitude, a.latitude], zoom: a.zoom || 12 }) }, + create_note: { tag: 'folk-markdown', build: (a) => ({ value: a.title ? `# ${a.title}\n\n${a.content}` : a.content }) }, + create_destination: { tag: 'folk-destination', build: (a) => a }, + create_itinerary: { tag: 'folk-itinerary', build: (a) => { + let items = []; try { items = JSON.parse(a.itemsJson); } catch {} + return { tripTitle: a.tripTitle, items }; + }}, + create_booking: { tag: 'folk-booking', build: (a) => a }, + create_budget: { tag: 'folk-budget', build: (a) => { + let expenses = []; try { expenses = JSON.parse(a.expensesJson); } catch {} + return { budgetTotal: a.budgetTotal, currency: a.currency, expenses }; + }}, + create_packing_list: { tag: 'folk-packing-list', build: (a) => { + let items = []; try { items = JSON.parse(a.itemsJson); } catch {} + return { items }; + }}, + }; + const startPos = getViewportCenter(); + let colIdx = 0; + for (const item of items) { + const mapping = TOOL_MAP[item.type]; + if (!mapping) continue; + const props = mapping.build(item.props); + const pos = { + x: startPos.x + (colIdx % 3) * 340 - 340, + y: startPos.y + Math.floor(colIdx / 3) * 280 - 100, + }; + newShape(mapping.tag, props, pos); + colIdx++; + } + } catch (e) { console.error('[Canvas] Failed to import trip items:', e); } + }, 400); } else if (hash === "#trip-planner") { history.replaceState(null, "", location.pathname + location.search); setTimeout(() => {