From f8e51cb83d8c3e675178490d7f4472e29fa7fcdb Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 3 Nov 2026 08:58:22 +0100 Subject: [PATCH] fix(pdf): hand the trip PDF to Safari from the iOS Home Screen app An iPhone or iPad running TREK from the Home Screen ignores print(), in the page and in an iframe alike, so "Save as PDF" in the preview did nothing there. Mobile Safari prints the same preview fine. In the installed iOS app the preview now shows "Open in Safari" in place of the dead button, with a line saying why. The link opens the trip with ?export=pdf, and both shells open their export dialog or sheet for it, so the PDF is one tap away once Safari is up. The parameter is dropped once read and survives the login redirect. Everywhere else the preview prints as before. The preview overlay moves out of TripPDF.tsx into pdfPreview.ts, which TripPDF had no room left for under lint:size, and its backdrop and stacking now use the --overlay and --z-modal tokens. --- client/scripts/rtl-baseline.json | 2 +- client/scripts/size-baseline.json | 1 - client/scripts/theme-baseline.json | 2 +- client/src/components/PDF/TripPDF.tsx | 97 +--------- client/src/components/PDF/pdfPreview.test.ts | 90 ++++++++++ client/src/components/PDF/pdfPreview.ts | 167 ++++++++++++++++++ .../Planner/DayPlanSidebarToolbar.test.tsx | 8 +- .../Planner/DayPlanSidebarToolbar.tsx | 3 + client/src/hooks/useExportDeepLink.test.tsx | 49 +++++ client/src/hooks/useExportDeepLink.ts | 28 +++ .../screens/trip/sheets/MExportSheet.tsx | 3 + client/src/utils/pdfSafariHandoff.test.ts | 15 ++ client/src/utils/pdfSafariHandoff.ts | 14 ++ .../unit/mobile/trip/MExportSheet.test.tsx | 8 +- shared/src/i18n/ar/pdf.ts | 3 + shared/src/i18n/az/pdf.ts | 3 + shared/src/i18n/br/pdf.ts | 3 + shared/src/i18n/ca/pdf.ts | 3 + shared/src/i18n/cs/pdf.ts | 3 + shared/src/i18n/de/pdf.ts | 3 + shared/src/i18n/en/pdf.ts | 3 + shared/src/i18n/es/pdf.ts | 3 + shared/src/i18n/et/pdf.ts | 3 + shared/src/i18n/fr/pdf.ts | 3 + shared/src/i18n/gr/pdf.ts | 3 + shared/src/i18n/he/pdf.ts | 3 + shared/src/i18n/hu/pdf.ts | 3 + shared/src/i18n/id/pdf.ts | 3 + shared/src/i18n/it/pdf.ts | 3 + shared/src/i18n/ja/pdf.ts | 3 + shared/src/i18n/ko/pdf.ts | 3 + shared/src/i18n/nl/pdf.ts | 3 + shared/src/i18n/pl/pdf.ts | 3 + shared/src/i18n/ru/pdf.ts | 3 + shared/src/i18n/sk/pdf.ts | 3 + shared/src/i18n/sv/pdf.ts | 3 + shared/src/i18n/th/pdf.ts | 2 + shared/src/i18n/tr/pdf.ts | 3 + shared/src/i18n/uk/pdf.ts | 3 + shared/src/i18n/vi/pdf.ts | 3 + shared/src/i18n/zh-TW/pdf.ts | 2 + shared/src/i18n/zh/pdf.ts | 2 + 42 files changed, 469 insertions(+), 99 deletions(-) create mode 100644 client/src/components/PDF/pdfPreview.test.ts create mode 100644 client/src/components/PDF/pdfPreview.ts create mode 100644 client/src/hooks/useExportDeepLink.test.tsx create mode 100644 client/src/hooks/useExportDeepLink.ts create mode 100644 client/src/utils/pdfSafariHandoff.test.ts create mode 100644 client/src/utils/pdfSafariHandoff.ts 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)} - - -
- ` - - 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 ` - ` -} - -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: ``, + 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 ` + `; +} + +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': '路线总览',