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': '路线总览',