From 1b7ea2c87d3b400e5ee5186c26ef8fd8711b2346 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 16 Apr 2026 15:54:07 +0200 Subject: [PATCH] fix(journey): replace window.open with srcdoc iframe overlay for PDF preview Rewrites downloadJourneyBookPDF to render the preview in an in-page srcdoc iframe overlay instead of calling window.open(), which Safari iOS PWA blocks in async callbacks. Matches the existing TripPDF pattern. Fixes #679. --- .../components/PDF/JourneyBookPDF.test.tsx | 52 ++++++++++--------- client/src/components/PDF/JourneyBookPDF.tsx | 51 +++++++++++------- 2 files changed, 60 insertions(+), 43 deletions(-) diff --git a/client/src/components/PDF/JourneyBookPDF.test.tsx b/client/src/components/PDF/JourneyBookPDF.test.tsx index bb43e711..1e8c5810 100644 --- a/client/src/components/PDF/JourneyBookPDF.test.tsx +++ b/client/src/components/PDF/JourneyBookPDF.test.tsx @@ -1,8 +1,8 @@ // FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006 // // JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)` -// that opens a new browser window and writes a full HTML document into it. -// It does NOT render a React component. Tests verify window.open behaviour. +// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern). +// Tests verify the overlay DOM structure and HTML content. import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; @@ -77,55 +77,57 @@ function buildJourney(overrides: Partial = {}): JourneyDetail { } as unknown as JourneyDetail; } -// ── Mock window.open ───────────────────────────────────────────────────────── +// ── Helpers to inspect the overlay ─────────────────────────────────────────── -let mockWindow: { - document: { write: ReturnType; close: ReturnType }; - focus: ReturnType; -}; +function getOverlay(): HTMLElement | null { + return document.getElementById('journey-pdf-overlay'); +} -beforeEach(() => { - mockWindow = { - document: { write: vi.fn(), close: vi.fn() }, - focus: vi.fn(), - }; - vi.spyOn(window, 'open').mockReturnValue(mockWindow as any); -}); +function getIframe(): HTMLIFrameElement | null { + return getOverlay()?.querySelector('iframe') ?? null; +} + +// ── Setup ──────────────────────────────────────────────────────────────────── afterEach(() => { + document.getElementById('journey-pdf-overlay')?.remove(); vi.restoreAllMocks(); }); // ── Tests ──────────────────────────────────────────────────────────────────── describe('downloadJourneyBookPDF', () => { - it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => { + it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => { await downloadJourneyBookPDF(buildJourney()); - expect(window.open).toHaveBeenCalledWith('', '_blank'); + expect(getOverlay()).not.toBeNull(); + expect(document.body.contains(getOverlay())).toBe(true); }); - it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => { + it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => { await downloadJourneyBookPDF(buildJourney()); - expect(mockWindow.document.write).toHaveBeenCalledTimes(1); - const html = mockWindow.document.write.mock.calls[0][0] as string; + const iframe = getIframe(); + expect(iframe).not.toBeNull(); + const html = iframe!.srcdoc; expect(html).toContain(''); expect(html).toContain(''); }); - it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => { + it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => { await downloadJourneyBookPDF(buildJourney()); - expect(mockWindow.document.close).toHaveBeenCalledTimes(1); + const overlay = getOverlay()!; + expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull(); + expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull(); }); it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => { await downloadJourneyBookPDF(buildJourney()); - const html = mockWindow.document.write.mock.calls[0][0] as string; + const html = getIframe()!.srcdoc; expect(html).toContain('Iceland Ring Road'); }); it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => { await downloadJourneyBookPDF(buildJourney()); - const html = mockWindow.document.write.mock.calls[0][0] as string; + const html = getIframe()!.srcdoc; expect(html).toContain('Golden Circle'); // Story text is rendered via markdown expect(html).toContain('An incredible day of geysers and waterfalls.'); @@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => { it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => { const journey = buildJourney({ entries: [] }); await downloadJourneyBookPDF(journey); - expect(window.open).toHaveBeenCalled(); - const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(getOverlay()).not.toBeNull(); + const html = getIframe()!.srcdoc; expect(html).toContain('Iceland Ring Road'); // No entry pages, but cover and closing page are still present expect(html).toContain('Journey Book'); diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx index 80d38333..97de76ec 100644 --- a/client/src/components/PDF/JourneyBookPDF.tsx +++ b/client/src/components/PDF/JourneyBookPDF.tsx @@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) { .entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; } } - .print-bar { - position: fixed; top: 0; left: 0; right: 0; z-index: 9999; - background: rgba(15,23,42,0.95); backdrop-filter: blur(12px); - padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px; - } - .print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; } - .print-bar .btn-save { background: white; color: #0f172a; } - .print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); } - .print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); } -
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) { ` - const win = window.open('', '_blank') - if (!win) return - win.document.write(html) - win.document.close() + // Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF. + // This avoids window.open() which Safari iOS blocks in async callbacks + // and window.close() which doesn't work reliably in standalone PWA mode. + const overlay = document.createElement('div') + overlay.id = 'journey-pdf-overlay' + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);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:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);' + + const header = document.createElement('div') + header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 20px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;' + header.innerHTML = ` + ${esc(journey.title)} · ${totalPages} pages +
+ + +
+ ` + + const iframe = document.createElement('iframe') + iframe.style.cssText = 'flex:1;width:100%;border:none;' + iframe.sandbox = 'allow-same-origin allow-modals' + iframe.srcdoc = html + + card.appendChild(header) + card.appendChild(iframe) + overlay.appendChild(card) + document.body.appendChild(overlay) + + header.querySelector('#journey-pdf-close')!.onclick = () => overlay.remove() + header.querySelector('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() } }