chore: apply prettier on the entire project

This commit is contained in:
jubnl
2026-05-25 21:59:42 +02:00
parent c130ed41be
commit 6bcdfbc34b
488 changed files with 82986 additions and 45830 deletions
+376 -231
View File
@@ -1,225 +1,346 @@
// 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, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters'
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)
);
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 }
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 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' }
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 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 }
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' })
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>`
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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
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
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
let _renderToStaticMarkup = null;
async function ensureRenderer() {
if (!_renderToStaticMarkup) {
const mod = await import('react-dom/server')
_renderToStaticMarkup = mod.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)' })
)
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' })
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' })}`
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
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 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))
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
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
);
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
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)
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 photoMap = await fetchPlacePhotos(assignments);
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
).size
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)
.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 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 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 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
})
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)
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'))
// 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)
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
? `<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') 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 `
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')
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 `
<div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span>
@@ -229,12 +350,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${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>`
}
</div>`;
}
if (item.type === 'note') {
const note = item.data
return `
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>
@@ -242,33 +363,37 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
</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'
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
// 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}">
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>`
</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('')
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 `
return `
<div class="place-card">
<div class="place-bar" style="background:${color}"></div>
${thumbHtml}
@@ -283,31 +408,35 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${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('')
</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 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 || '')
: ''
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 `
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>` : ''}
@@ -315,16 +444,18 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${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('')
</div>`;
})
.join('');
const accommodationsHtml = accommodationsForDay.length > 0
? `<div class="day-accommodations-overview">
const accommodationsHtml =
accommodationsForDay.length > 0
? `<div class="day-accommodations-overview">
<div class="day-accommodations ${accommodationsForDay.length === 1 ? 'single' : ''}">${accommodationDetails}</div>
</div>`
: ''
: '';
return `
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>
@@ -333,8 +464,9 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${cost ? `<span class="day-cost">${cost}</span>` : ''}
</div>
<div class="day-body">${accommodationsHtml}${itemsHtml}</div>
</div>`
}).join('')
</div>`;
})
.join('');
const html = `<!DOCTYPE html>
<html lang="${loc.split('-')[0]}">
@@ -509,9 +641,11 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<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>`}
${
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>` : ''}
@@ -530,10 +664,14 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="cover-stat-num">${totalAssigned}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
</div>
${totalCost > 0 ? `<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>
</div>
@@ -541,19 +679,24 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<!-- Days -->
${daysHtml}
</body></html>`
</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 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 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;'
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">
@@ -562,18 +705,20 @@ ${daysHtml}
<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 allow-scripts'
iframe.srcdoc = html
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)
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() }
header.querySelector('#pdf-close-btn').onclick = () => overlay.remove();
header.querySelector('#pdf-print-btn').onclick = () => {
iframe.contentWindow?.print();
};
}