mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
002ea91be8
* chore: bump version to 3.0.0 [skip ci]
* fix: resolve dead wiki links across install and config pages
* fix(reservations): restore correct day assignment for non-transport bookings
v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.
- Migration 122: for every non-hotel reservation, null out any
day_id / end_day_id that does not match the reservation's time,
then backfill it from reservation_time / reservation_end_time.
Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
derive day_id / end_day_id from reservation_time /
reservation_end_time when the client didn't send one explicitly,
so the mismatch cannot reappear on new or edited bookings.
Hotels are skipped because they store their date range on the
linked day_accommodation.
* chore: bump version to 3.0.1 [skip ci]
* fix(oidc): normalize discovery doc issuer before comparison
Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.
Closes #834
* test(systemNotices): exclude v3 upgrade notices from login_count-only tests
Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.
* chore: bump version to 3.0.2 [skip ci]
* fix(oidc): normalize id_token iss claim before issuer comparison (#837)
jwt.verify does an exact string match on the issuer. Providers like
Authentik include a trailing slash in the id_token iss claim while the
configured issuer is already normalized (no trailing slash), causing
every login attempt to fail with jwt issuer invalid.
Move the issuer check out of jwt.verify options and apply the same
trailing-slash normalization used in the discovery doc validation.
Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing
slash, wrong issuer, and wrong audience cases.
Closes #834
* chore: bump version to 3.0.3 [skip ci]
* fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845)
OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery
doc's issuer for id_token comparison instead of rejecting a path
mismatch as an error. Authentik (and similar realm-path providers)
return a canonical issuer like /application/o/<slug>/ that differs
from the operator's base OIDC_ISSUER. Strict equality blocked login
in 3.x despite working in v2. Default discovery (no custom URL) keeps
the strict check. Adds OIDC-SVC-037/038/039.
UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h
paddingBottom offset that other overlays already use. On mobile portrait
the action buttons were hidden behind the sticky bottom nav bar.
Closes #843
Closes #844
* chore: bump version to 3.0.4 [skip ci]
* fix(files): open attachments only in new tab (#840)
window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead.
* chore: bump version to 3.0.5 [skip ci]
* fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848)
* fix(journey): make sort_order authoritative for within-day entry ordering
Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.
- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019
Closes #846
* fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847)
Reservations were matched to days by pickup date only, so the end-day
card (e.g. car Return, flight Arrival) was silently dropped from the PDF.
Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id
span, show reservation_end_time on end days, prefix title with phase label
(Return/Arrival/etc.), and use per-day position for sort order.
* test(pdf): add missing day_id to transport reservation fixture
* chore: bump version to 3.0.6 [skip ci]
* [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849)
* fix: server/package.json & server/package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-UUID-16133035
* fix: bump fast-xml-parser version
---------
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: jubnl <jgunther021@gmail.com>
* chore: bump version to 3.0.7 [skip ci]
* fix: hot fixes 23-04-2026 (#856)
* fix(packing): resolve avatar URL path in bag and category assignees (#854)
packingService was returning raw avatar filenames from the DB instead of
the full /uploads/avatars/<filename> path, causing broken profile images
for users with uploaded avatars.
* fix(budget): use Map.get() to fix category rename no-op (#855)
* fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863)
- Change Helmet default from no-referrer to strict-origin-when-cross-origin
so browsers send the origin on cross-origin requests, allowing Google Maps
API key restrictions by HTTP referrer to work correctly
- Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts:
.env.example, docker-compose.yml, README.md, unraid-template.xml,
charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md
* fix(planner): prefetch budget items on trip page mount (#864)
Loads budgetItems alongside reservations when TripPlannerPage mounts so
the Budget category dropdown in ReservationModal and TransportModal shows
pre-existing categories on first open, regardless of whether the Budget
tab has been visited.
Closes #861
* fix(reservations): prevent Invalid Date when end time is set without end date (#866)
When reservation_end_time held a bare time string ("HH:MM"), fmtDate()
produced Invalid Date on the reservation card.
- Modal: when end date is blank but end time is filled, construct a
same-day ISO datetime using the start date (prevents time-only strings
from ever being persisted)
- Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD")
still show the multi-day range, while bare time strings are skipped and
handled correctly by the existing time column logic
Closes #860
* fix(planner): format reservation end time instead of rendering raw ISO string (#867)
Closes #859
* fix(planner): wire Route toggle into mobile day sidebar (#850) (#868)
The per-booking Route icon was missing on mobile because the mobile
DayPlanSidebar invocation in TripPlannerPage didn't pass
visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't
activate reservation map overlays without forcing desktop mode.
Also corrects the Map-Features wiki: fixes the setting name
("Booking route labels" not "Show connection labels"), documents the
route_calculation requirement for travel-time pills, and explains that
overlays are off by default and must be toggled per reservation.
* chore: bump version to 3.0.8 [skip ci]
---------
Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: Xre0uS <36565320+Xre0uS@users.noreply.github.com>
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
574 lines
29 KiB
TypeScript
574 lines
29 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, 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'
|
||
|
||
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 = `<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 || 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, 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 + (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
|
||
? `<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 = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||
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
|
||
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('')
|
||
|
||
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||
).sort((a, b) => a.start_day_id - b.start_day_id)
|
||
|
||
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;'
|
||
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() }
|
||
}
|