diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json
index f068a4fde..699f4c2c8 100644
--- a/client/scripts/rtl-baseline.json
+++ b/client/scripts/rtl-baseline.json
@@ -34,7 +34,7 @@
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/nightPauseMarker.ts": 4,
"components/Map/ratingBadge.ts": 1,
- "components/PDF/TripPDF.tsx": 8,
+ "components/PDF/TripPDF.tsx": 7,
"components/PDF/tripMapImage.ts": 1,
"components/Packing/PackingListPanelBagCard.tsx": 1,
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json
index 72a08a761..b08cfde73 100644
--- a/client/scripts/size-baseline.json
+++ b/client/scripts/size-baseline.json
@@ -8,7 +8,6 @@
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
- "src/components/PDF/TripPDF.tsx": 1039,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json
index a39cef2ae..aab605717 100644
--- a/client/scripts/theme-baseline.json
+++ b/client/scripts/theme-baseline.json
@@ -71,7 +71,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
- "components/PDF/TripPDF.tsx": 53,
+ "components/PDF/TripPDF.tsx": 51,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
"components/Packing/PackingListPanelHeader.tsx": 5,
diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx
index 10cb3a4ba..3691a3706 100644
--- a/client/src/components/PDF/TripPDF.tsx
+++ b/client/src/components/PDF/TripPDF.tsx
@@ -8,7 +8,8 @@ import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit,
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
import { safeHexColor } from '../../utils/safeColor'
-import { pdfSwitchOn, rememberPdfSwitch } from '../../utils/pdfSwitch'
+import { pdfSwitchOn } from '../../utils/pdfSwitch'
+import { openPdfPreview, PAGE_BREAK_KEY, TRANSPORT_NOTES_KEY } from './pdfPreview'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
import { useSettingsStore } from '../../store/settingsStore'
@@ -23,28 +24,6 @@ import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/fligh
import { onlyMyPlan, planAssignments } from './pdfScope'
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
-/**
- * Every day starts a new page by default. On a trip of short days that prints
- * one sheet per handful of lines, so the preview offers the flowing layout the
- * in-app view already uses, and remembers which one was picked (#1292).
- */
-const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day'
-
-/**
- * The notes of a flight, train or rental (#1571): printed by default, like the
- * notes of a place, and a switch in the preview leaves them out for a reader who
- * wants the plan without the fine print.
- */
-const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes'
-
-/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
- rebuilt here rather than rendered — as static markup it would carry no
- behaviour. Same geometry and the same tokens, so it reads as the same control. */
-const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;'
-const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);'
-const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)')
-const knobOffset = (on: boolean) => (on ? '22px' : '2px')
-
function renderLucideIcon(icon:LucideIcon, props = {}) {
return renderIconMarkup(
createElement(icon, props)
@@ -891,80 +870,10 @@ ${pluginSectionsHtml}
markdown.dispose()
if (signal?.aborted) return
- // 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;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
- header.innerHTML = `
- ${escHtml(trip?.title || tr('pdf.travelPlan'))}
-
- ${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
- ${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
- ${tr('pdf.saveAsPdf')}
-
-
-
-
- `
-
- const iframe = document.createElement('iframe')
- iframe.style.cssText = 'flex:1;width:100%;border:none;'
- // No script runs inside the document (print is parent-initiated), so withhold
- // allow-scripts to keep the sandbox tight.
- iframe.sandbox = 'allow-same-origin allow-modals'
- iframe.srcdoc = html
-
- card.appendChild(header)
- card.appendChild(iframe)
- overlay.appendChild(card)
- document.body.appendChild(overlay)
-
- const closeBtn = header.querySelector('#pdf-close-btn')
- if (closeBtn) closeBtn.onclick = () => overlay.remove()
- const printBtn = header.querySelector('#pdf-print-btn')
- if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
-
- // Both choices differ by one class on , so switching is instant in the
- // preview and there is no need to re-fetch photos or rebuild the document.
- wirePreviewSwitch(header, 'pdf-daybreak-toggle', on => {
- rememberPdfSwitch(PAGE_BREAK_KEY, on)
- iframe.contentDocument?.body.classList.toggle('pdf-flow', !on)
- })
- wirePreviewSwitch(header, 'pdf-transport-notes-toggle', on => {
- rememberPdfSwitch(TRANSPORT_NOTES_KEY, on)
- iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on)
- })
+ openPdfPreview({ html, title: trip?.title || tr('pdf.travelPlan'), tripId: trip?.id, tr, breaksPerDay, showTransportNotes, hasTransportNotes })
}
function bodyClassAttr(breaksPerDay: boolean, showTransportNotes: boolean): string {
const classes = [!breaksPerDay && 'pdf-flow', !showTransportNotes && 'pdf-no-transport-notes'].filter(Boolean)
return classes.length ? ` class="${classes.join(' ')}"` : ''
}
-
-/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
-function previewSwitch(id: string, label: string, on: boolean): string {
- return `${escHtml(label)}
-
-
- `
-}
-
-function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
- const button = header.querySelector(`#${id}`)
- if (!button) return
- button.onclick = () => {
- const on = button.getAttribute('aria-checked') !== 'true'
- button.setAttribute('aria-checked', String(on))
- button.style.background = trackColour(on)
- const knob = button.firstElementChild as HTMLElement | null
- if (knob) knob.style.left = knobOffset(on)
- onChange(on)
- }
-}
diff --git a/client/src/components/PDF/pdfPreview.test.ts b/client/src/components/PDF/pdfPreview.test.ts
new file mode 100644
index 000000000..ef8ed870c
--- /dev/null
+++ b/client/src/components/PDF/pdfPreview.test.ts
@@ -0,0 +1,90 @@
+/**
+ * FE-PDFPREVIEW-001 to FE-PDFPREVIEW-005: the trip PDF's preview and the control
+ * that saves it. An iPhone or iPad ignores print() in the Home Screen app, so
+ * there the preview hands the export to Safari instead of offering a button that
+ * does nothing.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { PAGE_BREAK_KEY, openPdfPreview, type PdfPreviewOptions } from './pdfPreview';
+
+const tr = (key: string) => key;
+
+function open(overrides: Partial = {}) {
+ openPdfPreview({
+ html: 'plan',
+ title: 'Lisbon',
+ tripId: 9,
+ tr,
+ breaksPerDay: true,
+ showTransportNotes: true,
+ hasTransportNotes: false,
+ ...overrides,
+ });
+}
+
+function standalone(value: boolean) {
+ Object.defineProperty(navigator, 'standalone', { configurable: true, get: () => value });
+}
+
+afterEach(() => {
+ document.getElementById('pdf-preview-overlay')?.remove();
+ delete (navigator as unknown as Record).standalone;
+ localStorage.clear();
+});
+
+describe('openPdfPreview', () => {
+ it('FE-PDFPREVIEW-001: a browser gets the print button, and it prints the frame', () => {
+ open();
+ expect(document.getElementById('pdf-safari-link')).toBeNull();
+ expect(document.getElementById('pdf-safari-hint')).toBeNull();
+
+ const iframe = document.querySelector('#pdf-preview-overlay iframe');
+ const print = vi.fn();
+ Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true });
+ const button = document.getElementById('pdf-print-btn');
+ expect(button?.textContent).toBe('pdf.saveAsPdf');
+ button?.click();
+ expect(print).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-PDFPREVIEW-002: the iOS Home Screen app gets a Safari link to the trip export and says why', () => {
+ standalone(true);
+ open();
+ expect(document.getElementById('pdf-print-btn')).toBeNull();
+
+ const link = document.getElementById('pdf-safari-link') as HTMLAnchorElement;
+ expect(link.textContent).toBe('pdf.openInSafari');
+ expect(link.target).toBe('_blank');
+ expect(link.rel).toBe('noopener');
+ const url = new URL(link.href);
+ expect(url.pathname).toBe('/trips/9');
+ expect(url.searchParams.get('export')).toBe('pdf');
+ expect(document.getElementById('pdf-safari-hint')?.textContent).toBe('pdf.safariHint');
+ });
+
+ it('FE-PDFPREVIEW-003: without a trip to hand over, the preview keeps the print button', () => {
+ standalone(true);
+ open({ tripId: undefined });
+ expect(document.getElementById('pdf-print-btn')).not.toBeNull();
+ expect(document.getElementById('pdf-safari-link')).toBeNull();
+ });
+
+ it('FE-PDFPREVIEW-004: the title is escaped and the close button removes the preview', () => {
+ open({ title: 'Lisbon ' });
+ const overlay = document.getElementById('pdf-preview-overlay');
+ expect(overlay?.querySelector('b')).toBeNull();
+ expect(overlay?.textContent).toContain('Lisbon ');
+ document.getElementById('pdf-close-btn')?.click();
+ expect(document.getElementById('pdf-preview-overlay')).toBeNull();
+ });
+
+ it('FE-PDFPREVIEW-005: the page-break switch flips and remembers the choice', () => {
+ open();
+ const toggle = document.getElementById('pdf-daybreak-toggle');
+ expect(toggle?.getAttribute('aria-checked')).toBe('true');
+ toggle?.click();
+ expect(toggle?.getAttribute('aria-checked')).toBe('false');
+ expect(localStorage.getItem(PAGE_BREAK_KEY)).not.toBeNull();
+ });
+});
diff --git a/client/src/components/PDF/pdfPreview.ts b/client/src/components/PDF/pdfPreview.ts
new file mode 100644
index 000000000..4c83b04e9
--- /dev/null
+++ b/client/src/components/PDF/pdfPreview.ts
@@ -0,0 +1,167 @@
+import { escapeHtml } from '@trek/shared';
+
+import { isIosStandalone } from '../../utils/iosDevice';
+import { pdfExportUrl } from '../../utils/pdfSafariHandoff';
+import { rememberPdfSwitch } from '../../utils/pdfSwitch';
+
+/**
+ * Every day starts a new page by default. On a trip of short days that prints
+ * one sheet per handful of lines, so the preview offers the flowing layout the
+ * in-app view already uses, and remembers which one was picked (#1292).
+ */
+export const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day';
+
+/**
+ * The notes of a flight, train or rental (#1571): printed by default, like the
+ * notes of a place, and a switch in the preview leaves them out for a reader who
+ * wants the plan without the fine print.
+ */
+export const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes';
+
+/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
+ rebuilt here rather than rendered — as static markup it would carry no
+ behaviour. Same geometry and the same tokens, so it reads as the same control. */
+const TOGGLE_TRACK =
+ 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;';
+const TOGGLE_KNOB =
+ 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);';
+const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)');
+const knobOffset = (on: boolean) => (on ? '22px' : '2px');
+
+const HEADER_ACTION =
+ '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';
+
+type Translate = (key: string, params?: Record) => string;
+
+export interface PdfPreviewOptions {
+ /** The finished document, shown in a sandboxed srcdoc frame. */
+ html: string;
+ title: string;
+ /** The trip the document prints, for the hand-over to Safari; without it the preview only prints. */
+ tripId?: number;
+ tr: Translate;
+ breaksPerDay: boolean;
+ showTransportNotes: boolean;
+ /** The transport-notes switch only shows when there is a note to hide. */
+ hasTransportNotes: boolean;
+}
+
+/**
+ * The control that saves the document. A browser prints the frame and offers
+ * "Save as PDF" in its dialog. An iPhone or iPad running TREK from the Home
+ * Screen ignores print() altogether, so there the control opens the trip in
+ * Safari with the export dialog up, and a line under the header says why.
+ */
+function saveControl(tripId: number | undefined, tr: Translate): { html: string; hint: string | null } {
+ if (tripId == null || !isIosStandalone()) {
+ return {
+ html: `${escapeHtml(tr('pdf.saveAsPdf'))} `,
+ hint: null,
+ };
+ }
+ return {
+ // target=_blank leaves the installed app for Safari; a link, not script, so the tap itself opens it.
+ html: `${escapeHtml(tr('pdf.openInSafari'))} `,
+ hint: tr('pdf.safariHint'),
+ };
+}
+
+/** Opens the document in a modal over the app, with its layout switches and the control that saves it. */
+export function openPdfPreview({
+ html,
+ title,
+ tripId,
+ tr,
+ breaksPerDay,
+ showTransportNotes,
+ hasTransportNotes,
+}: PdfPreviewOptions): void {
+ // 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:var(--overlay);z-index:var(--z-modal);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 save = saveControl(tripId, tr);
+ const header = document.createElement('div');
+ header.style.cssText =
+ 'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
+ header.innerHTML = `
+ ${escapeHtml(title)}
+
+ ${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
+ ${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
+ ${save.html}
+
+
+
+
+ `;
+
+ const iframe = document.createElement('iframe');
+ iframe.style.cssText = 'flex:1;width:100%;border:none;';
+ // No script runs inside the document (print is parent-initiated), so withhold
+ // allow-scripts to keep the sandbox tight.
+ iframe.sandbox = 'allow-same-origin allow-modals';
+ iframe.srcdoc = html;
+
+ card.appendChild(header);
+ if (save.hint) {
+ const hint = document.createElement('p');
+ hint.id = 'pdf-safari-hint';
+ hint.style.cssText =
+ 'margin:0;padding:8px 16px;font-size:12px;line-height:1.4;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border-primary);flex-shrink:0;';
+ hint.textContent = save.hint;
+ card.appendChild(hint);
+ }
+ card.appendChild(iframe);
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+
+ const closeBtn = header.querySelector('#pdf-close-btn');
+ if (closeBtn) closeBtn.onclick = () => overlay.remove();
+ const printBtn = header.querySelector('#pdf-print-btn');
+ if (printBtn)
+ printBtn.onclick = () => {
+ iframe.contentWindow?.print();
+ };
+
+ // Both choices differ by one class on , so switching is instant in the
+ // preview and there is no need to re-fetch photos or rebuild the document.
+ wirePreviewSwitch(header, 'pdf-daybreak-toggle', (on) => {
+ rememberPdfSwitch(PAGE_BREAK_KEY, on);
+ iframe.contentDocument?.body.classList.toggle('pdf-flow', !on);
+ });
+ wirePreviewSwitch(header, 'pdf-transport-notes-toggle', (on) => {
+ rememberPdfSwitch(TRANSPORT_NOTES_KEY, on);
+ iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on);
+ });
+}
+
+/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
+function previewSwitch(id: string, label: string, on: boolean): string {
+ return `${escapeHtml(label)}
+
+
+ `;
+}
+
+function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
+ const button = header.querySelector(`#${id}`);
+ if (!button) return;
+ button.onclick = () => {
+ const on = button.getAttribute('aria-checked') !== 'true';
+ button.setAttribute('aria-checked', String(on));
+ button.style.background = trackColour(on);
+ const knob = button.firstElementChild as HTMLElement | null;
+ if (knob) knob.style.insetInlineStart = knobOffset(on);
+ onChange(on);
+ };
+}
diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx
index 523b92f5c..b6c779084 100644
--- a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx
@@ -1,4 +1,4 @@
-// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
+// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-027
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
@@ -84,6 +84,12 @@ describe('DayPlanSidebarToolbar', () => {
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
})
+ it('FE-PLANNER-DPTOOLBAR-027: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the dialog', () => {
+ render( , { initialEntries: ['/trips/1?export=pdf'] })
+ expect(screen.getByRole('button', { name: 'dayplan.export' })).toHaveAttribute('aria-expanded', 'true')
+ expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
+ })
+
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
const user = userEvent.setup()
render( )
diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx
index e2d6278e4..53abbe5a7 100644
--- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx
+++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx
@@ -6,6 +6,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { BarButton, PANEL_BAR } from './planParts'
import { useToast } from '../shared/Toast'
import { TripExportModal } from './TripExportModal'
+import { useExportDeepLink } from '../../hooks/useExportDeepLink'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
@@ -63,6 +64,8 @@ export function DayPlanSidebarToolbar({
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
+ // The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
+ useExportDeepLink(() => setExportOpen(true))
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const expandLabel = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
diff --git a/client/src/hooks/useExportDeepLink.test.tsx b/client/src/hooks/useExportDeepLink.test.tsx
new file mode 100644
index 000000000..1104b34a2
--- /dev/null
+++ b/client/src/hooks/useExportDeepLink.test.tsx
@@ -0,0 +1,49 @@
+/**
+ * FE-EXPORTLINK-001 to FE-EXPORTLINK-003: `?export=pdf` opens the trip's export
+ * once, the way Safari lands after the iOS Home Screen app handed the PDF over.
+ */
+import { renderHook } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { MemoryRouter, useLocation } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+
+import { useExportDeepLink } from './useExportDeepLink';
+
+function setup(entry: string) {
+ const openExport = vi.fn();
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const { result, rerender } = renderHook(
+ () => {
+ useExportDeepLink(openExport);
+ return useLocation();
+ },
+ { wrapper }
+ );
+ return { openExport, result, rerender };
+}
+
+describe('useExportDeepLink', () => {
+ it('FE-EXPORTLINK-001: opens the export and drops the parameter, keeping the others', () => {
+ const { openExport, result, rerender } = setup('/trips/7?tab=plan&export=pdf');
+ expect(openExport).toHaveBeenCalledTimes(1);
+ expect(result.current.pathname).toBe('/trips/7');
+ expect(result.current.search).toBe('?tab=plan');
+
+ rerender();
+ expect(openExport).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-EXPORTLINK-002: leaves the trip alone without the parameter', () => {
+ const { openExport, result } = setup('/trips/7?tab=plan');
+ expect(openExport).not.toHaveBeenCalled();
+ expect(result.current.search).toBe('?tab=plan');
+ });
+
+ it('FE-EXPORTLINK-003: ignores an export it does not know', () => {
+ const { openExport, result } = setup('/trips/7?export=gpx');
+ expect(openExport).not.toHaveBeenCalled();
+ expect(result.current.search).toBe('?export=gpx');
+ });
+});
diff --git a/client/src/hooks/useExportDeepLink.ts b/client/src/hooks/useExportDeepLink.ts
new file mode 100644
index 000000000..b95b620c2
--- /dev/null
+++ b/client/src/hooks/useExportDeepLink.ts
@@ -0,0 +1,28 @@
+import { useEffect, useEffectEvent } from 'react';
+import { useSearchParams } from 'react-router';
+
+import { EXPORT_PARAM, EXPORT_PDF } from '../utils/pdfSafariHandoff';
+
+/**
+ * Opens the trip's export when the address asks for it (`?export=pdf`), the way
+ * the iOS Home Screen app hands a PDF over to Safari. The parameter is dropped
+ * once read, so a reload or a copied link does not open the dialog again.
+ */
+export function useExportDeepLink(openExport: () => void): void {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const requested = searchParams.get(EXPORT_PARAM) === EXPORT_PDF;
+ // The shells pass a fresh closure on every render; the request is answered once.
+ const open = useEffectEvent(openExport);
+
+ useEffect(() => {
+ if (!requested) return;
+ open();
+ setSearchParams(
+ (p) => {
+ p.delete(EXPORT_PARAM);
+ return p;
+ },
+ { replace: true }
+ );
+ }, [requested, setSearchParams]);
+}
diff --git a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx
index 2889fa9a5..676aede24 100644
--- a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx
@@ -5,6 +5,7 @@ import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeMo
import { useTripStore } from '../../../../store/tripStore'
import { useTranslation } from '../../../../i18n'
import { useTripExport } from '../../../../components/Planner/useTripExport'
+import { useExportDeepLink } from '../../../../hooks/useExportDeepLink'
import { INNER_CLS, TileHeader } from './MTripSheetUi'
import type { MTripSheetsProps } from '../MTripShell'
import type { LucideIcon } from 'lucide-react'
@@ -20,6 +21,8 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
const open = shell.sheet?.id === 'export'
const dayNotes = useTripStore(s => s.dayNotes)
const [subscribeOpen, setSubscribeOpen] = useState(false)
+ // The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
+ useExportDeepLink(() => shell.openSheet('export'))
// Fed the way the desktop dialog feeds it: the store's assignments, and the export
// applies the day plan's filter itself, so both shells print the same. The PDF stays
// open here; a calendar or GPX file closes the sheet.
diff --git a/client/src/utils/pdfSafariHandoff.test.ts b/client/src/utils/pdfSafariHandoff.test.ts
new file mode 100644
index 000000000..453ce9425
--- /dev/null
+++ b/client/src/utils/pdfSafariHandoff.test.ts
@@ -0,0 +1,15 @@
+/**
+ * FE-PDFSAFARI-001: the link the iOS Home Screen app hands to Safari.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { EXPORT_PARAM, EXPORT_PDF, pdfExportUrl } from './pdfSafariHandoff';
+
+describe('pdfExportUrl', () => {
+ it('FE-PDFSAFARI-001: is the absolute trip address with the export asked for', () => {
+ const url = new URL(pdfExportUrl(42));
+ expect(url.origin).toBe(window.location.origin);
+ expect(url.pathname).toBe('/trips/42');
+ expect(url.searchParams.get(EXPORT_PARAM)).toBe(EXPORT_PDF);
+ });
+});
diff --git a/client/src/utils/pdfSafariHandoff.ts b/client/src/utils/pdfSafariHandoff.ts
new file mode 100644
index 000000000..a4c492ca7
--- /dev/null
+++ b/client/src/utils/pdfSafariHandoff.ts
@@ -0,0 +1,14 @@
+/**
+ * An iPhone or iPad ignores print() in an app added to the Home Screen, in the
+ * page and in an iframe alike, so the trip PDF cannot be saved from there. Safari
+ * can print it. This is the link that hands the export over: it opens the trip in
+ * Safari with the export dialog already up.
+ */
+
+export const EXPORT_PARAM = 'export';
+export const EXPORT_PDF = 'pdf';
+
+/** The trip, opened with its export dialog showing. Absolute, since it leaves the installed app. */
+export function pdfExportUrl(tripId: number): string {
+ return `${window.location.origin}/trips/${tripId}?${EXPORT_PARAM}=${EXPORT_PDF}`;
+}
diff --git a/client/tests/unit/mobile/trip/MExportSheet.test.tsx b/client/tests/unit/mobile/trip/MExportSheet.test.tsx
index 2f0ea4321..0871e364f 100644
--- a/client/tests/unit/mobile/trip/MExportSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MExportSheet.test.tsx
@@ -9,7 +9,7 @@ import { server } from '../../../helpers/msw/server'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
-// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-015
+// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-016
// This sheet reads its copy from useTranslation(), so the assertions are English.
vi.mock('../../../../src/components/PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() }))
@@ -248,4 +248,10 @@ describe('MExportSheet', () => {
expect(route?.signal?.aborted).toBe(true)
expect(signal?.aborted).toBe(true)
})
+
+ it('FE-MOB-EXPSH-016: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the sheet', () => {
+ const shell = buildShell({ sheet: null })
+ render( , { initialEntries: ['/trips/1?export=pdf'] })
+ expect(shell.openSheet).toHaveBeenCalledWith('export')
+ })
})
diff --git a/shared/src/i18n/ar/pdf.ts b/shared/src/i18n/ar/pdf.ts
index cec71b342..456d673b3 100644
--- a/shared/src/i18n/ar/pdf.ts
+++ b/shared/src/i18n/ar/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'التكلفة',
'pdf.preview': 'معاينة PDF',
'pdf.saveAsPdf': 'حفظ كـ PDF',
+ 'pdf.openInSafari': 'فتح في Safari',
+ 'pdf.safariHint':
+ 'لا يمكن لنظام iOS الطباعة من التطبيق على الشاشة الرئيسية. سيفتح Safari هذه الرحلة مع تجهيز التصدير، ويمكنك حفظ ملف PDF هناك.',
'pdf.pageBreakPerDay': 'فاصل صفحة لكل يوم',
'pdf.transportNotes': 'ملاحظات التنقل',
'pdf.mapTitle': 'نظرة عامة على المسار',
diff --git a/shared/src/i18n/az/pdf.ts b/shared/src/i18n/az/pdf.ts
index fb6c1daff..0d36a4536 100644
--- a/shared/src/i18n/az/pdf.ts
+++ b/shared/src/i18n/az/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Xərc',
'pdf.preview': 'PDF önizləməsi',
'pdf.saveAsPdf': 'PDF kimi yadda saxla',
+ 'pdf.openInSafari': 'Safari-də aç',
+ 'pdf.safariHint':
+ 'iOS Əsas ekrandakı tətbiqdən çap edə bilmir. Safari bu səfəri ixrac hazır vəziyyətdə açır, PDF-i orada saxlaya bilərsən.',
'pdf.pageBreakPerDay': 'Hər gün üçün yeni səhifə',
'pdf.transportNotes': 'Nəqliyyat qeydləri',
'pdf.mapTitle': 'Marşrutun icmalı',
diff --git a/shared/src/i18n/br/pdf.ts b/shared/src/i18n/br/pdf.ts
index 959390116..88058eaff 100644
--- a/shared/src/i18n/br/pdf.ts
+++ b/shared/src/i18n/br/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Custo',
'pdf.preview': 'Pré-visualização do PDF',
'pdf.saveAsPdf': 'Salvar como PDF',
+ 'pdf.openInSafari': 'Abrir no Safari',
+ 'pdf.safariHint':
+ 'O iOS não consegue imprimir pelo app na Tela de Início. O Safari abre esta viagem com a exportação pronta, e lá você pode salvar o PDF.',
'pdf.pageBreakPerDay': 'Quebra de página por dia',
'pdf.transportNotes': 'Notas de transporte',
'pdf.mapTitle': 'Visão geral da rota',
diff --git a/shared/src/i18n/ca/pdf.ts b/shared/src/i18n/ca/pdf.ts
index 5be7fa596..2e8446ee8 100644
--- a/shared/src/i18n/ca/pdf.ts
+++ b/shared/src/i18n/ca/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Cost EUR',
'pdf.preview': 'Vista prèvia del PDF',
'pdf.saveAsPdf': 'Desa com a PDF',
+ 'pdf.openInSafari': 'Obre a Safari',
+ 'pdf.safariHint':
+ "iOS no pot imprimir des de l'app de la pantalla d'inici. Safari obre aquest viatge amb l'exportació a punt i allà pots desar el PDF.",
'pdf.pageBreakPerDay': 'Salt de pàgina per dia',
'pdf.transportNotes': 'Notes del transport',
'pdf.mapTitle': 'Resum de la ruta',
diff --git a/shared/src/i18n/cs/pdf.ts b/shared/src/i18n/cs/pdf.ts
index 4c6b59265..c594e2dc0 100644
--- a/shared/src/i18n/cs/pdf.ts
+++ b/shared/src/i18n/cs/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Náklady',
'pdf.preview': 'Náhled PDF',
'pdf.saveAsPdf': 'Uložit jako PDF',
+ 'pdf.openInSafari': 'Otevřít v Safari',
+ 'pdf.safariHint':
+ 'iOS neumí tisknout z aplikace na ploše. Safari otevře tento výlet s připraveným exportem a tam si PDF uložíte.',
'pdf.pageBreakPerDay': 'Zalomení stránky pro každý den',
'pdf.transportNotes': 'Poznámky k dopravě',
'pdf.mapTitle': 'Přehled trasy',
diff --git a/shared/src/i18n/de/pdf.ts b/shared/src/i18n/de/pdf.ts
index 2a77e1787..757151503 100644
--- a/shared/src/i18n/de/pdf.ts
+++ b/shared/src/i18n/de/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Kosten',
'pdf.preview': 'PDF Vorschau',
'pdf.saveAsPdf': 'Als PDF speichern',
+ 'pdf.openInSafari': 'In Safari öffnen',
+ 'pdf.safariHint':
+ 'iOS kann aus der App auf dem Home-Bildschirm nicht drucken. Safari öffnet diese Reise mit dem Export, dort kannst du das PDF speichern.',
'pdf.pageBreakPerDay': 'Seitenumbruch pro Tag',
'pdf.transportNotes': 'Notizen zu Transporten',
'pdf.mapTitle': 'Routenübersicht',
diff --git a/shared/src/i18n/en/pdf.ts b/shared/src/i18n/en/pdf.ts
index fd0817687..b89fe88ae 100644
--- a/shared/src/i18n/en/pdf.ts
+++ b/shared/src/i18n/en/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Cost',
'pdf.preview': 'PDF Preview',
'pdf.saveAsPdf': 'Save as PDF',
+ 'pdf.openInSafari': 'Open in Safari',
+ 'pdf.safariHint':
+ 'iOS cannot print from the app on your Home Screen. Safari opens this trip with the export ready, and there you can save the PDF.',
'pdf.pageBreakPerDay': 'Page break per day',
'pdf.transportNotes': 'Transport notes',
'pdf.mapTitle': 'Route overview',
diff --git a/shared/src/i18n/es/pdf.ts b/shared/src/i18n/es/pdf.ts
index 702ab866b..1674e95f5 100644
--- a/shared/src/i18n/es/pdf.ts
+++ b/shared/src/i18n/es/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Coste',
'pdf.preview': 'Vista previa PDF',
'pdf.saveAsPdf': 'Guardar como PDF',
+ 'pdf.openInSafari': 'Abrir en Safari',
+ 'pdf.safariHint':
+ 'iOS no puede imprimir desde la app de la pantalla de inicio. Safari abre este viaje con la exportación lista y allí puedes guardar el PDF.',
'pdf.pageBreakPerDay': 'Salto de página por día',
'pdf.transportNotes': 'Notas de transporte',
'pdf.mapTitle': 'Resumen de la ruta',
diff --git a/shared/src/i18n/et/pdf.ts b/shared/src/i18n/et/pdf.ts
index 855be5370..983c81887 100644
--- a/shared/src/i18n/et/pdf.ts
+++ b/shared/src/i18n/et/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Maksumus',
'pdf.preview': 'PDF-i eelvaade',
'pdf.saveAsPdf': 'Salvesta PDF-ina',
+ 'pdf.openInSafari': 'Ava Safaris',
+ 'pdf.safariHint':
+ 'iOS ei saa avakuval olevast rakendusest printida. Safari avab selle reisi valmis ekspordiga ja seal saad PDF-i salvestada.',
'pdf.pageBreakPerDay': 'Iga päev uuel lehel',
'pdf.transportNotes': 'Transpordi märkmed',
'pdf.mapTitle': 'Marsruudi ülevaade',
diff --git a/shared/src/i18n/fr/pdf.ts b/shared/src/i18n/fr/pdf.ts
index 96f71e794..ed6c41b54 100644
--- a/shared/src/i18n/fr/pdf.ts
+++ b/shared/src/i18n/fr/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Coût',
'pdf.preview': 'Aperçu PDF',
'pdf.saveAsPdf': 'Enregistrer en PDF',
+ 'pdf.openInSafari': 'Ouvrir dans Safari',
+ 'pdf.safariHint':
+ "iOS ne peut pas imprimer depuis l'app de l'écran d'accueil. Safari ouvre ce voyage avec l'export prêt, et vous pourrez y enregistrer le PDF.",
'pdf.pageBreakPerDay': 'Saut de page par jour',
'pdf.transportNotes': 'Notes de transport',
'pdf.mapTitle': "Aperçu de l'itinéraire",
diff --git a/shared/src/i18n/gr/pdf.ts b/shared/src/i18n/gr/pdf.ts
index a79e07c09..4460a6f16 100644
--- a/shared/src/i18n/gr/pdf.ts
+++ b/shared/src/i18n/gr/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Κόστος',
'pdf.preview': 'Προεπισκόπηση PDF',
'pdf.saveAsPdf': 'Αποθήκευση ως PDF',
+ 'pdf.openInSafari': 'Άνοιγμα στο Safari',
+ 'pdf.safariHint':
+ 'Το iOS δεν μπορεί να εκτυπώσει από την εφαρμογή στην οθόνη Αφετηρίας. Το Safari ανοίγει αυτό το ταξίδι με έτοιμη την εξαγωγή, εκεί μπορείς να αποθηκεύσεις το PDF.',
'pdf.pageBreakPerDay': 'Αλλαγή σελίδας ανά ημέρα',
'pdf.transportNotes': 'Σημειώσεις μεταφορών',
'pdf.mapTitle': 'Επισκόπηση διαδρομής',
diff --git a/shared/src/i18n/he/pdf.ts b/shared/src/i18n/he/pdf.ts
index aac95f4f1..49a4b8475 100644
--- a/shared/src/i18n/he/pdf.ts
+++ b/shared/src/i18n/he/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'עלות',
'pdf.preview': 'תצוגה מקדימה של PDF',
'pdf.saveAsPdf': 'שמירה כ-PDF',
+ 'pdf.openInSafari': 'פתיחה ב-Safari',
+ 'pdf.safariHint':
+ 'מערכת iOS לא יכולה להדפיס מהאפליקציה שבמסך הבית. Safari יפתח את הטיול הזה עם הייצוא מוכן, ושם אפשר לשמור את ה-PDF.',
'pdf.pageBreakPerDay': 'מעבר עמוד לכל יום',
'pdf.transportNotes': 'הערות תחבורה',
'pdf.mapTitle': 'סקירת המסלול',
diff --git a/shared/src/i18n/hu/pdf.ts b/shared/src/i18n/hu/pdf.ts
index b21332010..ee0c79325 100644
--- a/shared/src/i18n/hu/pdf.ts
+++ b/shared/src/i18n/hu/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Költség',
'pdf.preview': 'PDF előnézet',
'pdf.saveAsPdf': 'Mentés PDF-ként',
+ 'pdf.openInSafari': 'Megnyitás Safariban',
+ 'pdf.safariHint':
+ 'Az iOS nem tud nyomtatni a kezdőképernyőn lévő appból. A Safari megnyitja ezt az utat az előkészített exporttal, ott mentheted a PDF-et.',
'pdf.pageBreakPerDay': 'Oldaltörés naponta',
'pdf.transportNotes': 'Közlekedési jegyzetek',
'pdf.mapTitle': 'Útvonal áttekintése',
diff --git a/shared/src/i18n/id/pdf.ts b/shared/src/i18n/id/pdf.ts
index a95efe414..2a43c263a 100644
--- a/shared/src/i18n/id/pdf.ts
+++ b/shared/src/i18n/id/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Biaya',
'pdf.preview': 'Pratinjau PDF',
'pdf.saveAsPdf': 'Simpan sebagai PDF',
+ 'pdf.openInSafari': 'Buka di Safari',
+ 'pdf.safariHint':
+ 'iOS tidak bisa mencetak dari aplikasi di Layar Utama. Safari membuka perjalanan ini dengan ekspor yang siap, di sana kamu bisa menyimpan PDF.',
'pdf.pageBreakPerDay': 'Pemisah halaman per hari',
'pdf.transportNotes': 'Catatan transportasi',
'pdf.mapTitle': 'Ringkasan rute',
diff --git a/shared/src/i18n/it/pdf.ts b/shared/src/i18n/it/pdf.ts
index fccc17b2f..4adadbf18 100644
--- a/shared/src/i18n/it/pdf.ts
+++ b/shared/src/i18n/it/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Costo',
'pdf.preview': 'Anteprima PDF',
'pdf.saveAsPdf': 'Salva come PDF',
+ 'pdf.openInSafari': 'Apri in Safari',
+ 'pdf.safariHint':
+ "iOS non può stampare dall'app nella schermata Home. Safari apre questo viaggio con l'esportazione pronta, lì puoi salvare il PDF.",
'pdf.pageBreakPerDay': 'Interruzione di pagina per giorno',
'pdf.transportNotes': 'Note sui trasporti',
'pdf.mapTitle': 'Panoramica del percorso',
diff --git a/shared/src/i18n/ja/pdf.ts b/shared/src/i18n/ja/pdf.ts
index 4eb5b4d06..f8aa394b7 100644
--- a/shared/src/i18n/ja/pdf.ts
+++ b/shared/src/i18n/ja/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': '費用',
'pdf.preview': 'PDFプレビュー',
'pdf.saveAsPdf': 'PDFとして保存',
+ 'pdf.openInSafari': 'Safari で開く',
+ 'pdf.safariHint':
+ 'iOS ではホーム画面のアプリから印刷できません。Safari でこの旅行をエクスポートの準備ができた状態で開き、そこで PDF を保存できます。',
'pdf.pageBreakPerDay': '日ごとに改ページ',
'pdf.transportNotes': '交通機関のメモ',
'pdf.mapTitle': 'ルート概要',
diff --git a/shared/src/i18n/ko/pdf.ts b/shared/src/i18n/ko/pdf.ts
index 8b733bec4..b5623881c 100644
--- a/shared/src/i18n/ko/pdf.ts
+++ b/shared/src/i18n/ko/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': '비용',
'pdf.preview': 'PDF 미리보기',
'pdf.saveAsPdf': 'PDF로 저장',
+ 'pdf.openInSafari': 'Safari에서 열기',
+ 'pdf.safariHint':
+ 'iOS에서는 홈 화면 앱에서 인쇄할 수 없습니다. Safari에서 내보내기가 준비된 상태로 이 여행이 열리며, 거기서 PDF를 저장할 수 있습니다.',
'pdf.pageBreakPerDay': '날짜별 페이지 나누기',
'pdf.transportNotes': '교통편 메모',
'pdf.mapTitle': '경로 개요',
diff --git a/shared/src/i18n/nl/pdf.ts b/shared/src/i18n/nl/pdf.ts
index b5cef3ea0..aeb47cf22 100644
--- a/shared/src/i18n/nl/pdf.ts
+++ b/shared/src/i18n/nl/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Kosten',
'pdf.preview': 'PDF-voorbeeld',
'pdf.saveAsPdf': 'Opslaan als PDF',
+ 'pdf.openInSafari': 'Openen in Safari',
+ 'pdf.safariHint':
+ 'iOS kan niet afdrukken vanuit de app op je beginscherm. Safari opent deze reis met de export klaar, daar kun je de PDF opslaan.',
'pdf.pageBreakPerDay': 'Pagina-einde per dag',
'pdf.transportNotes': 'Notities bij vervoer',
'pdf.mapTitle': 'Routeoverzicht',
diff --git a/shared/src/i18n/pl/pdf.ts b/shared/src/i18n/pl/pdf.ts
index 464358722..ce358a646 100644
--- a/shared/src/i18n/pl/pdf.ts
+++ b/shared/src/i18n/pl/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Koszt',
'pdf.preview': 'Podgląd PDF',
'pdf.saveAsPdf': 'Zapisz jako PDF',
+ 'pdf.openInSafari': 'Otwórz w Safari',
+ 'pdf.safariHint':
+ 'iOS nie może drukować z aplikacji na ekranie początkowym. Safari otworzy tę podróż z gotowym eksportem i tam zapiszesz PDF.',
'pdf.pageBreakPerDay': 'Podział strony dla każdego dnia',
'pdf.transportNotes': 'Notatki do transportu',
'pdf.mapTitle': 'Przegląd trasy',
diff --git a/shared/src/i18n/ru/pdf.ts b/shared/src/i18n/ru/pdf.ts
index 7b30688f7..401bd459b 100644
--- a/shared/src/i18n/ru/pdf.ts
+++ b/shared/src/i18n/ru/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Стоимость',
'pdf.preview': 'Предпросмотр PDF',
'pdf.saveAsPdf': 'Сохранить как PDF',
+ 'pdf.openInSafari': 'Открыть в Safari',
+ 'pdf.safariHint':
+ 'iOS не может печатать из приложения на экране «Домой». Safari откроет эту поездку с готовым экспортом, там можно сохранить PDF.',
'pdf.pageBreakPerDay': 'Разрыв страницы для каждого дня',
'pdf.transportNotes': 'Заметки к транспорту',
'pdf.mapTitle': 'Обзор маршрута',
diff --git a/shared/src/i18n/sk/pdf.ts b/shared/src/i18n/sk/pdf.ts
index 174143573..5a73dbb7b 100644
--- a/shared/src/i18n/sk/pdf.ts
+++ b/shared/src/i18n/sk/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Náklady',
'pdf.preview': 'Náhľad PDF',
'pdf.saveAsPdf': 'Uložiť ako PDF',
+ 'pdf.openInSafari': 'Otvoriť v Safari',
+ 'pdf.safariHint':
+ 'iOS nedokáže tlačiť z aplikácie na ploche. Safari otvorí tento výlet s pripraveným exportom a tam si PDF uložíte.',
'pdf.pageBreakPerDay': 'Zalomenie strany pre každý deň',
'pdf.transportNotes': 'Poznámky k doprave',
'pdf.mapTitle': 'Prehľad trasy',
diff --git a/shared/src/i18n/sv/pdf.ts b/shared/src/i18n/sv/pdf.ts
index a1f3c00a9..ae8e2e946 100644
--- a/shared/src/i18n/sv/pdf.ts
+++ b/shared/src/i18n/sv/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Kostnad',
'pdf.preview': 'Förhandsgranskning av PDF',
'pdf.saveAsPdf': 'Spara som PDF',
+ 'pdf.openInSafari': 'Öppna i Safari',
+ 'pdf.safariHint':
+ 'iOS kan inte skriva ut från appen på hemskärmen. Safari öppnar resan med exporten redo, där kan du spara PDF:en.',
'pdf.pageBreakPerDay': 'Sidbrytning per dag',
'pdf.transportNotes': 'Anteckningar om transporter',
'pdf.mapTitle': 'Ruttöversikt',
diff --git a/shared/src/i18n/th/pdf.ts b/shared/src/i18n/th/pdf.ts
index 991ed6116..27701e590 100644
--- a/shared/src/i18n/th/pdf.ts
+++ b/shared/src/i18n/th/pdf.ts
@@ -6,6 +6,8 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'ราคา',
'pdf.preview': 'ดูตัวอย่าง PDF',
'pdf.saveAsPdf': 'บันทึกเป็น PDF',
+ 'pdf.openInSafari': 'เปิดใน Safari',
+ 'pdf.safariHint': 'iOS พิมพ์จากแอปบนหน้าจอโฮมไม่ได้ Safari จะเปิดทริปนี้พร้อมการส่งออก และคุณบันทึก PDF ได้ที่นั่น',
'pdf.pageBreakPerDay': 'ขึ้นหน้าใหม่ทุกวัน',
'pdf.transportNotes': 'บันทึกการเดินทาง',
'pdf.mapTitle': 'ภาพรวมเส้นทาง',
diff --git a/shared/src/i18n/tr/pdf.ts b/shared/src/i18n/tr/pdf.ts
index b99d35e81..a7d7ff355 100644
--- a/shared/src/i18n/tr/pdf.ts
+++ b/shared/src/i18n/tr/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Maliyet',
'pdf.preview': 'PDF Önizleme',
'pdf.saveAsPdf': 'PDF olarak Kaydet',
+ 'pdf.openInSafari': "Safari'de aç",
+ 'pdf.safariHint':
+ "iOS, ana ekrandaki uygulamadan yazdıramaz. Safari bu geziyi dışa aktarma hazır şekilde açar, PDF'i orada kaydedebilirsin.",
'pdf.pageBreakPerDay': 'Her gün için sayfa sonu',
'pdf.transportNotes': 'Ulaşım notları',
'pdf.mapTitle': 'Rota genel görünümü',
diff --git a/shared/src/i18n/uk/pdf.ts b/shared/src/i18n/uk/pdf.ts
index 29a7d0811..66fafd5fb 100644
--- a/shared/src/i18n/uk/pdf.ts
+++ b/shared/src/i18n/uk/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Вартість',
'pdf.preview': 'Попередній перегляд PDF',
'pdf.saveAsPdf': 'Зберегти як PDF',
+ 'pdf.openInSafari': 'Відкрити в Safari',
+ 'pdf.safariHint':
+ 'iOS не може друкувати з застосунку на екрані «Додому». Safari відкриє цю подорож із готовим експортом, там можна зберегти PDF.',
'pdf.pageBreakPerDay': 'Розрив сторінки для кожного дня',
'pdf.transportNotes': 'Нотатки до транспорту',
'pdf.mapTitle': 'Огляд маршруту',
diff --git a/shared/src/i18n/vi/pdf.ts b/shared/src/i18n/vi/pdf.ts
index b419731e0..737c48564 100644
--- a/shared/src/i18n/vi/pdf.ts
+++ b/shared/src/i18n/vi/pdf.ts
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Chi phí',
'pdf.preview': 'PDF Xem trước',
'pdf.saveAsPdf': 'Lưu dưới dạng PDF',
+ 'pdf.openInSafari': 'Mở trong Safari',
+ 'pdf.safariHint':
+ 'iOS không thể in từ ứng dụng trên Màn hình chính. Safari sẽ mở chuyến đi này với bản xuất sẵn sàng, tại đó bạn có thể lưu PDF.',
'pdf.pageBreakPerDay': 'Ngắt trang cho mỗi ngày',
'pdf.transportNotes': 'Ghi chú phương tiện',
'pdf.mapTitle': 'Tổng quan lộ trình',
diff --git a/shared/src/i18n/zh-TW/pdf.ts b/shared/src/i18n/zh-TW/pdf.ts
index 5ce782af1..0e84941e7 100644
--- a/shared/src/i18n/zh-TW/pdf.ts
+++ b/shared/src/i18n/zh-TW/pdf.ts
@@ -6,6 +6,8 @@ const pdf: TranslationStrings = {
'pdf.costLabel': '費用',
'pdf.preview': 'PDF 預覽',
'pdf.saveAsPdf': '儲存為 PDF',
+ 'pdf.openInSafari': '在 Safari 中開啟',
+ 'pdf.safariHint': 'iOS 無法從主畫面上的 App 列印。Safari 會開啟此行程並準備好匯出,你可以在那裡儲存 PDF。',
'pdf.pageBreakPerDay': '每天分頁',
'pdf.transportNotes': '交通備註',
'pdf.mapTitle': '路線總覽',
diff --git a/shared/src/i18n/zh/pdf.ts b/shared/src/i18n/zh/pdf.ts
index 04b8c9b1b..b6cbd68ea 100644
--- a/shared/src/i18n/zh/pdf.ts
+++ b/shared/src/i18n/zh/pdf.ts
@@ -6,6 +6,8 @@ const pdf: TranslationStrings = {
'pdf.costLabel': '费用',
'pdf.preview': 'PDF 预览',
'pdf.saveAsPdf': '保存为 PDF',
+ 'pdf.openInSafari': '在 Safari 中打开',
+ 'pdf.safariHint': 'iOS 无法从主屏幕上的应用打印。Safari 会打开此行程并准备好导出,你可以在那里保存 PDF。',
'pdf.pageBreakPerDay': '每天分页',
'pdf.transportNotes': '交通备注',
'pdf.mapTitle': '路线总览',