Date-only strings parsed with new Date(dateStr + 'T00:00:00') were
interpreted relative to the local timezone, causing off-by-one day
display for users west of UTC. Fixed across 16 files by parsing as
UTC ('T00:00:00Z') and displaying with timeZone: 'UTC'.
465 lines
22 KiB
TypeScript
465 lines
22 KiB
TypeScript
// 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 = `<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
|
||
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, 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)
|
||
|
||
// 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
|
||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||
: 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 `
|
||
<div class="note-card" style="border-left: 3px solid #3b82f6;">
|
||
<div class="note-line" style="background: #3b82f6;"></div>
|
||
<span class="note-icon">${icon}</span>
|
||
<div class="note-body">
|
||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</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
|
||
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
|
||
? `<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.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('')
|
||
|
||
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">${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; }
|
||
|
||
/* ── 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;'
|
||
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() }
|
||
}
|