Spaces:
Sleeping
Sleeping
| // 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, Sailboat, Bike, CarTaxiFront, Route, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react' | |
| import { accommodationsApi, mapsApi } from '../../api/client' | |
| import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types' | |
| import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder' | |
| import { splitReservationDateTime } from '../../utils/formatters' | |
| 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, taxi: CarTaxiFront, bicycle: Bike, cruise: Ship, ferry: Sailboat, transport_other: Route, restaurant: Utensils, event: Ticket, tour: Users, other: FileText } | |
| const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#059669', car: '#6b7280', taxi: '#ca8a04', bicycle: '#84cc16', cruise: '#0ea5e9', ferry: '#0d9488', transport_other: '#6b7280', 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 = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>` | |
| const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>` | |
| const svgClock2= `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>` | |
| const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>` | |
| const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>` | |
| function escHtml(str) { | |
| if (!str) return '' | |
| return String(str).replace(/&/g, '&').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 | |
| // The in-app place-photo proxy always streams a JPEG but has no file extension | |
| // (it ends in β¦/bytes), so the extension check below would wrongly reject it β | |
| // which is why persisted place photos showed as category icons in the PDF. | |
| if (url.startsWith('/api/maps/place-photo/')) return absUrl(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: AssignmentsMap) { | |
| 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()] | |
| // Assignment places are a server-side projection that omits osm_id, so photo | |
| // pre-fetch keys off the google_place_id that the projection does carry. | |
| 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, 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[] | |
| // Flattened across days: each note carries its own day_id (see downloadTripPDF callers). | |
| dayNotes: DayNote[] | |
| reservations?: any[] | |
| t: (key: string, params?: Record<string, string | number>) => 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 + (Number(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 ?? 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 | |
| ? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>` | |
| : 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') { | |
| // Full route over all waypoints (FRA β BER β HND), falling back to the | |
| // flat metadata pair for legacy single-leg flights without endpoints. | |
| const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name) | |
| const route = stops.length >= 2 ? stops.join(' β ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} β ${meta.arrival_airport}` : '') | |
| subtitle = [meta.airline, meta.flight_number, route].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 ` | |
| <div class="note-card" style="border-left: 3px solid ${color};"> | |
| <div class="note-line" style="background: ${color};"></div> | |
| <span class="note-icon">${icon}</span> | |
| <div class="note-body"> | |
| <div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div> | |
| ${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''} | |
| ${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''} | |
| ${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''} | |
| </div> | |
| </div>` | |
| } | |
| if (item.type === 'note') { | |
| const note = item.data | |
| return ` | |
| <div class="note-card"> | |
| <div class="note-line"></div> | |
| <span class="note-icon">${noteIconSvg(note.icon)}</span> | |
| <div class="note-body"> | |
| <div class="note-text">${escHtml(note.text)}</div> | |
| ${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''} | |
| </div> | |
| </div>` | |
| } | |
| 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. Both go through safeImg | |
| // so the proxy path is resolved to an absolute URL the PDF can load. | |
| const directImg = safeImg(place.image_url) | |
| const googleImg = safeImg(photoMap[place.id]) | |
| const img = directImg || googleImg | |
| const iconSvg = categoryIconSvg(cat?.icon, color, 24) | |
| const thumbHtml = img | |
| ? `<img class="place-thumb" src="${escHtml(img)}" />` | |
| : `<div class="place-thumb-fallback" style="background:${color}"> | |
| ${iconSvg} | |
| </div>` | |
| const chips = [ | |
| place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '', | |
| place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>` : '', | |
| ].filter(Boolean).join('') | |
| return ` | |
| <div class="place-card"> | |
| <div class="place-bar" style="background:${color}"></div> | |
| ${thumbHtml} | |
| <div class="place-info"> | |
| <div class="place-name-row"> | |
| <span class="place-num">${pi}</span> | |
| <span class="place-name">${escHtml(place.name)}</span> | |
| ${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''} | |
| </div> | |
| ${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''} | |
| ${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''} | |
| ${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''} | |
| ${chips ? `<div class="chips">${chips}</div>` : ''} | |
| ${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''} | |
| </div> | |
| </div>` | |
| }).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 ` | |
| <div class="day-accommodation"> | |
| <div class="day-accommodation-title accommodation-center-icon">${actionIcon} ${escHtml(actionLabel)}</div> | |
| ${timeStr ? `<div class="accommodation-center-icon">${accommodationIconSvg('checkin')} <b>${escHtml(timeStr)}</b></div>` : ''} | |
| <div class="accommodation-center-icon">${accommodationIconSvg('accommodation')} ${escHtml(item.place_name)}</div> | |
| ${item.place_address ? `<div class="accommodation-center-icon">${accommodationIconSvg('location')} ${escHtml(item.place_address)}</div>` : ''} | |
| ${item.notes ? `<div class="accommodation-center-icon">${accommodationIconSvg('note')} ${escHtml(item.notes)}</div>` : ''} | |
| ${isCheckIn && item.confirmation ? `<div class="accommodation-center-icon">${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}</div>` : ''} | |
| </div>` | |
| }).join('') | |
| const accommodationsHtml = accommodationsForDay.length > 0 | |
| ? `<div class="day-accommodations-overview"> | |
| <div class="day-accommodations ${accommodationsForDay.length === 1 ? 'single' : ''}">${accommodationDetails}</div> | |
| </div>` | |
| : '' | |
| return ` | |
| <div class="day-section${di > 0 ? ' page-break' : ''}"> | |
| <div class="day-header"> | |
| <span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span> | |
| <span class="day-title">${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))}</span> | |
| ${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''} | |
| ${cost ? `<span class="day-cost">${cost}</span>` : ''} | |
| </div> | |
| <div class="day-body">${accommodationsHtml}${itemsHtml}</div> | |
| </div>` | |
| }).join('') | |
| const html = `<!DOCTYPE html> | |
| <html lang="${loc.split('-')[0]}"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <base href="${window.location.origin}/"> | |
| <title>${escHtml(trip?.title || tr('pdf.travelPlan'))}</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; } | |
| svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; } | |
| /* Footer on every printed page */ | |
| .pdf-footer { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 0; | |
| right: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 6px; | |
| opacity: 0.3; | |
| } | |
| .pdf-footer span { | |
| font-size: 7px; | |
| color: #64748b; | |
| letter-spacing: 0.5px; | |
| } | |
| /* ββ Cover βββββββββββββββββββββββββββββββββββββββ */ | |
| .cover { | |
| width: 100%; min-height: 100vh; | |
| background: #0f172a; | |
| display: flex; flex-direction: column; justify-content: flex-end; | |
| padding: 52px; position: relative; overflow: hidden; | |
| } | |
| .cover-bg { | |
| position: absolute; inset: 0; | |
| background-size: cover; background-position: center; | |
| opacity: 0.28; | |
| } | |
| .cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); } | |
| .cover-brand { | |
| position: absolute; top: 36px; right: 52px; | |
| z-index: 2; | |
| } | |
| .cover-body { position: relative; z-index: 1; } | |
| .cover-circle { | |
| width: 100px; height: 100px; border-radius: 50%; | |
| overflow: hidden; border: 2.5px solid rgba(255,255,255,0.25); | |
| margin-bottom: 26px; flex-shrink: 0; | |
| } | |
| .cover-circle img { width: 100%; height: 100%; object-fit: cover; } | |
| .cover-circle-ph { | |
| width: 100px; height: 100px; border-radius: 50%; | |
| background: rgba(255,255,255,0.07); | |
| margin-bottom: 26px; | |
| } | |
| .cover-label { font-size: 9px; font-weight: 600; letter-spacing: 2.5px; color: rgba(255,255,255,0.4); text-transform: uppercase; margin-bottom: 8px; } | |
| .cover-title { font-size: 42px; font-weight: 700; color: #fff; line-height: 1.1; margin-bottom: 8px; } | |
| .cover-desc { font-size: 13px; color: rgba(255,255,255,0.55); line-height: 1.6; margin-bottom: 18px; max-width: 420px; } | |
| .cover-dates { font-size: 12px; color: rgba(255,255,255,0.45); margin-bottom: 30px; } | |
| .cover-line { height: 1px; background: rgba(255,255,255,0.1); margin-bottom: 24px; } | |
| .cover-stats { display: flex; gap: 36px; } | |
| .cover-stat-num { font-size: 28px; font-weight: 700; color: #fff; line-height: 1; } | |
| .cover-stat-lbl { font-size: 9px; font-weight: 500; color: rgba(255,255,255,0.4); letter-spacing: 1px; margin-top: 4px; text-transform: uppercase; } | |
| /* ββ Day βββββββββββββββββββββββββββββββββββββββββ */ | |
| .page-break { page-break-before: always; } | |
| .day-header { | |
| background: #0f172a; padding: 11px 28px; | |
| display: flex; align-items: center; gap: 8px; | |
| } | |
| .day-tag { font-size: 8px; font-weight: 700; color: #fff; letter-spacing: 0.8px; background: rgba(255,255,255,0.12); border-radius: 4px; padding: 3px 8px; flex-shrink: 0; } | |
| .day-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; } | |
| .day-date { font-size: 9px; color: rgba(255,255,255,0.45); } | |
| .day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); } | |
| .day-body { padding: 12px 28px 6px; } | |
| /* accommodation info */ | |
| .day-accommodations-overview { font-size: 12px; } | |
| .day-accommodations { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; } | |
| .day-accommodations.single { justify-content: center; } | |
| .day-accommodation { | |
| flex: 1 1 45%; min-width: 200px; margin: 4px 0; padding: 10px; | |
| border: 2px solid #e2e8f0; border-radius: 12px; | |
| display: flex; flex-direction: column; | |
| } | |
| .day-accommodation-title { | |
| font-size: 16px; font-weight: 600; text-align: center; | |
| margin-bottom: 4px; align-self: center; | |
| } | |
| .accommodation-center-icon { display: flex; align-items: center; gap: 4px; } | |
| /* ββ Place card ββββββββββββββββββββββββββββββββββ */ | |
| .place-card { | |
| display: flex; align-items: stretch; | |
| border: 1px solid #e2e8f0; border-radius: 8px; | |
| margin-bottom: 8px; overflow: hidden; | |
| background: #fff; page-break-inside: avoid; | |
| } | |
| .place-bar { width: 4px; flex-shrink: 0; } | |
| .place-thumb { | |
| width: 52px; height: 52px; object-fit: cover; | |
| margin: 8px; border-radius: 6px; flex-shrink: 0; | |
| } | |
| .place-thumb-fallback { | |
| width: 52px; height: 52px; margin: 8px; border-radius: 8px; | |
| flex-shrink: 0; display: flex; align-items: center; justify-content: center; | |
| } | |
| .place-thumb-fallback svg { width: 24px; height: 24px; } | |
| .place-info { flex: 1; padding: 9px 10px 8px 0; min-width: 0; } | |
| .place-name-row { display: flex; align-items: center; gap: 5px; margin-bottom: 4px; } | |
| .place-num { | |
| width: 16px; height: 16px; border-radius: 50%; | |
| background: #1e293b; color: #fff; font-size: 8px; font-weight: 700; | |
| display: flex; align-items: center; justify-content: center; flex-shrink: 0; | |
| } | |
| .place-name { font-size: 11.5px; font-weight: 600; color: #1e293b; flex: 1; } | |
| .cat-badge { font-size: 7.5px; font-weight: 600; color: #fff; border-radius: 99px; padding: 2px 7px; flex-shrink: 0; white-space: nowrap; } | |
| .info-row { display: flex; align-items: flex-start; gap: 4px; margin-bottom: 2px; padding-left: 21px; } | |
| .info-row svg { flex-shrink: 0; margin-top: 1px; } | |
| .info-spacer { width: 13px; flex-shrink: 0; } | |
| .info-text { font-size: 9px; color: #64748b; line-height: 1.5; } | |
| .info-text.muted { color: #94a3b8; } | |
| .info-text.italic { font-style: italic; } | |
| .chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; } | |
| .chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; } | |
| .chip svg { flex-shrink: 0; } | |
| .chip-green { background: #ecfdf5; color: #059669; } | |
| .chip-amber { background: #fffbeb; color: #d97706; } | |
| /* ββ Note card βββββββββββββββββββββββββββββββββββ */ | |
| .note-card { | |
| display: flex; align-items: center; gap: 8px; | |
| background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; | |
| padding: 8px 10px; margin-bottom: 7px; page-break-inside: avoid; | |
| } | |
| .note-line { width: 3px; border-radius: 99px; background: #94a3b8; align-self: stretch; flex-shrink: 0; } | |
| .note-icon { flex-shrink: 0; } | |
| .note-body { flex: 1; min-width: 0; } | |
| .note-text { font-size: 9.5px; color: #334155; line-height: 1.55; } | |
| .note-time { font-size: 8px; color: #94a3b8; margin-top: 2px; } | |
| .empty-day { font-size: 9.5px; color: #cbd5e1; font-style: italic; text-align: center; padding: 14px 0; } | |
| /* ββ Print βββββββββββββββββββββββββββββββββββββββ */ | |
| @media print { | |
| body { margin: 0; } | |
| .cover { min-height: 100vh; page-break-after: always; } | |
| @page { margin: 0; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Footer on every page --> | |
| <div class="pdf-footer"> | |
| <span>made with</span> | |
| <img src="${absUrl('/logo-dark.svg')}" style="height:10px;opacity:0.6;" /> | |
| </div> | |
| <!-- Cover --> | |
| <div class="cover"> | |
| ${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''} | |
| <div class="cover-dim"></div> | |
| <div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div> | |
| <div class="cover-body"> | |
| ${coverImg | |
| ? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>` | |
| : `<div class="cover-circle-ph"></div>`} | |
| <div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div> | |
| <div class="cover-title">${escHtml(trip?.title || 'My Trip')}</div> | |
| ${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''} | |
| ${range ? `<div class="cover-dates">${range}</div>` : ''} | |
| <div class="cover-line"></div> | |
| <div class="cover-stats"> | |
| <div> | |
| <div class="cover-stat-num">${sorted.length}</div> | |
| <div class="cover-stat-lbl">${escHtml(tr('dashboard.days'))}</div> | |
| </div> | |
| <div> | |
| <div class="cover-stat-num">${places?.length || 0}</div> | |
| <div class="cover-stat-lbl">${escHtml(tr('dashboard.places'))}</div> | |
| </div> | |
| <div> | |
| <div class="cover-stat-num">${totalAssigned}</div> | |
| <div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div> | |
| </div> | |
| ${totalCost > 0 ? `<div> | |
| <div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div> | |
| <div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div> | |
| </div>` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Days --> | |
| ${daysHtml} | |
| </body></html>` | |
| // 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 = ` | |
| <span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span> | |
| <div style="display:flex;align-items:center;gap:8px"> | |
| <button id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button> | |
| <button id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> | |
| </button> | |
| </div> | |
| ` | |
| const iframe = document.createElement('iframe') | |
| iframe.style.cssText = 'flex:1;width:100%;border:none;' | |
| // No script runs inside the document (print is parent-initiated), so withhold | |
| // allow-scripts to keep the sandbox tight. | |
| iframe.sandbox = 'allow-same-origin allow-modals' | |
| iframe.srcdoc = html | |
| card.appendChild(header) | |
| card.appendChild(iframe) | |
| overlay.appendChild(card) | |
| document.body.appendChild(overlay) | |
| const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn') | |
| if (closeBtn) closeBtn.onclick = () => overlay.remove() | |
| const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn') | |
| if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() } | |
| } | |