// Trip PDF via browser print window import { createElement } from 'react' import { getCategoryIcon } from '../shared/categoryIcons' import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react' import { mapsApi } from '../../api/client' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } function noteIconSvg(iconId) { if (!_renderToStaticMarkup) return '' const Icon = NOTE_ICON_MAP[iconId] || FileText return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })) } const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } function transportIconSvg(type) { if (!_renderToStaticMarkup) return '' const Icon = TRANSPORT_ICON_MAP[type] || Ticket return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })) } // ── SVG inline icons (for chips) ───────────────────────────────────────────── const svgPin = `` const svgClock = `` const svgClock2= `` const svgCheck = `` const svgEuro = `` function escHtml(str) { if (!str) return '' return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } function absUrl(url) { if (!url) return null if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url return window.location.origin + (url.startsWith('/') ? '' : '/') + url } function safeImg(url) { if (!url) return null if (url.startsWith('https://') || url.startsWith('http://')) return url return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null } // Generate SVG string from Lucide icon name (for category thumbnails) let _renderToStaticMarkup = null async function ensureRenderer() { if (!_renderToStaticMarkup) { const mod = await import('react-dom/server') _renderToStaticMarkup = mod.renderToStaticMarkup } } function categoryIconSvg(iconName, color = '#6366f1', size = 24) { if (!_renderToStaticMarkup) return '' const Icon = getCategoryIcon(iconName) return _renderToStaticMarkup( createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' }) ) } function shortDate(d, locale) { if (!d) return '' return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } function longDateRange(days, locale) { const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number) if (!dd.length) return null const f = new Date(dd[0].date + 'T00:00:00Z') const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z') return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}` } function dayCost(assignments, dayId, locale) { const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) return total > 0 ? `${total.toLocaleString(locale)} EUR` : null } // Pre-fetch Google Place photos for all assigned places async function fetchPlacePhotos(assignments) { const photoMap = {} // placeId → photoUrl const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean) const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()] const toFetch = unique.filter(p => !p.image_url && p.google_place_id) await Promise.allSettled( toFetch.map(async (place) => { try { const data = await mapsApi.placePhoto(place.google_place_id) if (data.photoUrl) photoMap[place.id] = data.photoUrl } catch {} }) ) return photoMap } interface downloadTripPDFProps { trip: Trip days: Day[] places: Place[] assignments: AssignmentsMap categories: Category[] dayNotes: DayNotesMap reservations?: any[] t: (key: string, params?: Record) => string locale: string } export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) { await ensureRenderer() const loc = _locale || undefined const tr = _t || (k => k) const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number) const range = longDateRange(sorted, loc) const coverImg = safeImg(trip?.cover_image) // Pre-fetch place photos from Google const photoMap = await fetchPlacePhotos(assignments) const totalAssigned = new Set( Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean) ).size const totalCost = Object.values(assignments || {}) .flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) // Build day HTML const daysHtml = sorted.map((day, di) => { const assigned = assignments[String(day.id)] || [] const notes = (dayNotes || []).filter(n => n.day_id === day.id) const cost = dayCost(assignments, day.id, loc) // Transport bookings for this day const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const dayTransport = (reservations || []).filter(r => { if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false return day.date && r.reservation_time.split('T')[0] === day.date }) const merged = [] assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) dayTransport.forEach(r => { const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) merged.push({ type: 'transport', k: pos, data: r }) }) merged.sort((a, b) => a.k - b.k) let pi = 0 const itemsHtml = merged.length === 0 ? `
${escHtml(tr('dayplan.emptyDay'))}
` : merged.map(item => { if (item.type === 'transport') { const r = item.data const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) const icon = transportIconSvg(r.type) let subtitle = '' if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ') else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ') const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' return `
${icon}
${escHtml(r.title)}${time ? ` ${time}` : ''}
${subtitle ? `
${escHtml(subtitle)}
` : ''} ${r.confirmation_number ? `
Code: ${escHtml(r.confirmation_number)}
` : ''}
` } if (item.type === 'note') { const note = item.data return `
${noteIconSvg(note.icon)}
${escHtml(note.text)}
${note.time ? `
${escHtml(note.time)}
` : ''}
` } pi++ const place = item.data.place if (!place) return '' const cat = categories.find(c => c.id === place.category_id) const color = cat?.color || '#6366f1' // Image: direct > google photo > fallback icon const directImg = safeImg(place.image_url) const googleImg = photoMap[place.id] || null const img = directImg || googleImg const iconSvg = categoryIconSvg(cat?.icon, color, 24) const thumbHtml = img ? `` : `
${iconSvg}
` const chips = [ place.place_time ? `${svgClock}${escHtml(place.place_time)}` : '', place.price && parseFloat(place.price) > 0 ? `${svgEuro}${Number(place.price).toLocaleString(loc)} EUR` : '', ].filter(Boolean).join('') return `
${thumbHtml}
${pi} ${escHtml(place.name)} ${cat ? `${escHtml(cat.name)}` : ''}
${place.address ? `
${svgPin}${escHtml(place.address)}
` : ''} ${place.description ? `
${escHtml(place.description)}
` : ''} ${chips ? `
${chips}
` : ''} ${place.notes ? `
${escHtml(place.notes)}
` : ''}
` }).join('') return `
${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()} ${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))} ${day.date ? `${shortDate(day.date, loc)}` : ''} ${cost ? `${cost}` : ''}
${itemsHtml}
` }).join('') const html = ` ${escHtml(trip?.title || tr('pdf.travelPlan'))}
${coverImg ? `
` : ''}
${coverImg ? `
` : `
`}
${escHtml(tr('pdf.travelPlan'))}
${escHtml(trip?.title || 'My Trip')}
${trip?.description ? `
${escHtml(trip.description)}
` : ''} ${range ? `
${range}
` : ''}
${sorted.length}
${escHtml(tr('dashboard.days'))}
${places?.length || 0}
${escHtml(tr('dashboard.places'))}
${totalAssigned}
${escHtml(tr('pdf.planned'))}
${totalCost > 0 ? `
${totalCost.toLocaleString(loc)}
${escHtml(tr('pdf.costLabel'))}
` : ''}
${daysHtml} ` // Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue) const overlay = document.createElement('div') overlay.id = 'pdf-preview-overlay' overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;' overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() } const card = document.createElement('div') card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);' const header = document.createElement('div') header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;' header.innerHTML = ` ${escHtml(trip?.title || tr('pdf.travelPlan'))}
` const iframe = document.createElement('iframe') iframe.style.cssText = 'flex:1;width:100%;border:none;' iframe.sandbox = 'allow-same-origin allow-modals' iframe.srcdoc = html card.appendChild(header) card.appendChild(iframe) overlay.appendChild(card) document.body.appendChild(overlay) header.querySelector('#pdf-close-btn').onclick = () => overlay.remove() header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() } }