// Trip PDF via browser print window import { AlertTriangle, BedDouble, Bookmark, Bus, Camera, Car, Clock, Coffee, FileText, Flag, Heart, Hotel, Info, KeyRound, Lightbulb, LogIn, LogOut, LucideIcon, MapPin, Navigation, Plane, Ship, ShoppingBag, Star, Ticket, Train, Users, Utensils, } from 'lucide-react'; import { createElement } from 'react'; import { accommodationsApi, mapsApi } from '../../api/client'; import type { AssignmentsMap, Category, Day, DayNotesMap, Place, Trip } from '../../types'; import { getDayOrder, isDayInAccommodationRange } from '../../utils/dayOrder'; import { splitReservationDateTime } from '../../utils/formatters'; import { getCategoryIcon } from '../shared/categoryIcons'; function renderLucideIcon(icon: LucideIcon, props = {}) { if (!_renderToStaticMarkup) return ''; return _renderToStaticMarkup(createElement(icon, props)); } 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) { const Icon = NOTE_ICON_MAP[iconId] || FileText; return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }); } const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText, }; const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280', }; function reservationIconSvg(type) { const Icon = RESERVATION_ICON_MAP[type] || Ticket; const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'; return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color }); } const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound, }; function accommodationIconSvg(type) { const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble; return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' }); } // ── 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 || p.osm_id)); await Promise.allSettled( toFetch.map(async (place) => { try { const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name); 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); //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed const accommodations = await accommodationsApi.list(trip.id); // 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); // Span helpers for multi-day transport (mirrors DayPlanSidebar logic) const pdfGetDayOrder = (d: Day) => d.day_number; const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => { const startId = r.day_id; const endId = r.end_day_id ?? startId; if (!startId || startId === endId) return 'single'; if (dayId === startId) return 'start'; if (dayId === endId) return 'end'; return 'middle'; }; const pdfGetDisplayTime = (r: any, dayId: number): string | null => { const phase = pdfGetSpanPhase(r, dayId); if (phase === 'end') return r.reservation_end_time || null; if (phase === 'middle') return null; return r.reservation_time || null; }; const pdfGetSpanLabel = (r: any, phase: string): string | null => { if (phase === 'single') return null; if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`); if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`); return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`); }; const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter((r) => { if (r.type === 'hotel') return false; const startId = r.day_id; const endId = r.end_day_id ?? startId; if (startId == null) return false; if (endId !== startId) { const startDay = sorted.find((d) => d.id === startId); const endDay = sorted.find((d) => d.id === endId); const thisDay = sorted.find((d) => d.id === dayId); if (!startDay || !endDay || !thisDay) return false; return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay); } return startId === dayId; }); // 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); // Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only) const dayReservations = pdfGetTransportForDay(day.id).filter( (r) => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle') ); 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 })); dayReservations.forEach((r) => { const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map((m) => m.k)) + 0.5 : 0.5); merged.push({ type: 'reservation', 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 === 'reservation') { const r = item.data; const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : r.metadata || {}; const icon = reservationIconSvg(r.type); const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'; 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(' · '); else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · '); else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · '); else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · '); const locationLine = r.location || meta.location || ''; const phase = pdfGetSpanPhase(r, day.id); const spanLabel = pdfGetSpanLabel(r, phase); const displayTime = pdfGetDisplayTime(r, day.id); const time = splitReservationDateTime(displayTime).time ?? ''; const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`; return `
${icon}
${titleHtml}${time ? ` ${time}` : ''}
${subtitle ? `
${escHtml(subtitle)}
` : ''} ${locationLine ? `
${escHtml(locationLine)}
` : ''} ${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(''); const accommodationsForDay = (accommodations.accommodations || []) .filter((a) => (day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)) .sort((a, b) => { const startA = days.find((d) => d.id === a.start_day_id); const startB = days.find((d) => d.id === b.start_day_id); return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0); }); const accommodationDetails = accommodationsForDay .map((item) => { const isCheckIn = day.id === item.start_day_id; const isCheckOut = day.id === item.end_day_id; const actionLabel = isCheckIn ? tr('reservations.meta.checkIn') : isCheckOut ? tr('reservations.meta.checkOut') : tr('reservations.meta.linkAccommodation'); const actionIcon = isCheckIn ? accommodationIconSvg('checkin') : isCheckOut ? accommodationIconSvg('checkout') : accommodationIconSvg('accommodation'); const timeStr = isCheckIn ? item.check_in || '' : isCheckOut ? item.check_out || '' : ''; return `
${actionIcon} ${escHtml(actionLabel)}
${timeStr ? `
${accommodationIconSvg('checkin')} ${escHtml(timeStr)}
` : ''}
${accommodationIconSvg('accommodation')} ${escHtml(item.place_name)}
${item.place_address ? `
${accommodationIconSvg('location')} ${escHtml(item.place_address)}
` : ''} ${item.notes ? `
${accommodationIconSvg('note')} ${escHtml(item.notes)}
` : ''} ${isCheckIn && item.confirmation ? `
${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}
` : ''}
`; }) .join(''); const accommodationsHtml = accommodationsForDay.length > 0 ? `
${accommodationDetails}
` : ''; 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}` : ''}
${accommodationsHtml}${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 allow-scripts'; 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(); }; }