Render PDF receipts inline with react-pdf instead of an iframe
CI / test (push) Successful in 29s
CI / lint (push) Failing after 25s
CI / secrets-scan (push) Successful in 5s
CI / vuln-scan (push) Successful in 14s
CI / sast (push) Successful in 10s
CI / build-images (push) Has been skipped
CI / image-scan (push) Has been skipped
CI / push (push) Has been skipped

Browsers' native PDF viewer refused to load blob-URL PDFs inside the
ReceiptViewer iframe (the "Click here to load" placeholder did nothing,
even though Open-in-new-tab worked). Replace the iframe with react-pdf
rendering on a canvas so we no longer depend on the in-iframe viewer:
fit-to-width Page, paginated nav for multi-page docs, selectable text
layer, and a Print button. The viewer is React.lazy-loaded so image-only
users don't download the pdfjs worker chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 23:05:27 -07:00
parent 7adb2182fc
commit c13b580bac
9 changed files with 596 additions and 17 deletions
+207
View File
@@ -159,12 +159,18 @@ importers:
lucide-react:
specifier: ^1.8.0
version: 1.8.0(react@19.2.5)
pdfjs-dist:
specifier: ^5.7.284
version: 5.7.284
react:
specifier: ^19.2.4
version: 19.2.5
react-dom:
specifier: ^19.2.4
version: 19.2.5(react@19.2.5)
react-pdf:
specifier: ^10.4.1
version: 10.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-router-dom:
specifier: ^7.14.0
version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -1381,6 +1387,81 @@ packages:
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
engines: {node: '>=18'}
'@napi-rs/canvas-android-arm64@0.1.100':
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.100':
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.100':
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.100':
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.100':
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -4277,6 +4358,10 @@ packages:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -4308,6 +4393,9 @@ packages:
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-cancellable-promise@2.0.0:
resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
@@ -4315,6 +4403,9 @@ packages:
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
make-event-props@2.0.0:
resolution: {integrity: sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==}
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
@@ -4341,6 +4432,14 @@ packages:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
merge-refs@2.0.0:
resolution: {integrity: sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -4642,6 +4741,14 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfjs-dist@5.4.296:
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
engines: {node: '>=20.16.0 || >=22.3.0'}
pdfjs-dist@5.7.284:
resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==}
engines: {node: '>=22.13.0 || >=24'}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -4784,6 +4891,16 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-pdf@10.4.1:
resolution: {integrity: sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -5696,6 +5813,9 @@ packages:
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
watchpack@2.5.1:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'}
@@ -7409,6 +7529,54 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
'@napi-rs/canvas-android-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.100':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
optional: true
'@napi-rs/canvas@0.1.100':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.100
'@napi-rs/canvas-darwin-arm64': 0.1.100
'@napi-rs/canvas-darwin-x64': 0.1.100
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-musl': 0.1.100
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
optional: true
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.9.2
@@ -10588,6 +10756,10 @@ snapshots:
chalk: 5.6.2
is-unicode-supported: 1.3.0
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lru-cache@10.4.3: {}
lru-cache@11.3.3: {}
@@ -10620,12 +10792,16 @@ snapshots:
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-cancellable-promise@2.0.0: {}
make-dir@4.0.0:
dependencies:
semver: 7.7.4
make-error@1.3.6: {}
make-event-props@2.0.0: {}
makeerror@1.0.12:
dependencies:
tmpl: 1.0.5
@@ -10644,6 +10820,10 @@ snapshots:
merge-descriptors@2.0.0: {}
merge-refs@2.0.0(@types/react@19.2.14):
optionalDependencies:
'@types/react': 19.2.14
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -10934,6 +11114,14 @@ snapshots:
pathe@2.0.3: {}
pdfjs-dist@5.4.296:
optionalDependencies:
'@napi-rs/canvas': 0.1.100
pdfjs-dist@5.7.284:
optionalDependencies:
'@napi-rs/canvas': 0.1.100
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
@@ -11059,6 +11247,21 @@ snapshots:
react-is@18.3.1: {}
react-pdf@10.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
clsx: 2.1.1
dequal: 2.0.3
make-cancellable-promise: 2.0.0
make-event-props: 2.0.0
merge-refs: 2.0.0(@types/react@19.2.14)
pdfjs-dist: 5.4.296
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
tiny-invariant: 1.3.3
warning: 4.0.3
optionalDependencies:
'@types/react': 19.2.14
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -12071,6 +12274,10 @@ snapshots:
dependencies:
makeerror: 1.0.12
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
watchpack@2.5.1:
dependencies:
glob-to-regexp: 0.4.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tehriehlbudget-backend",
"version": "0.2.0",
"version": "0.3.0",
"description": "",
"author": "",
"private": true,
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tehriehlbudget-frontend",
"private": true,
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -23,8 +23,10 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"pdfjs-dist": "^5.7.284",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-pdf": "^10.4.1",
"react-router-dom": "^7.14.0",
"recharts": "^3.8.1",
"shadcn": "^4.2.0",
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import '@/lib/pdf-worker';
interface Props {
blob: Blob;
}
function useContainerWidth() {
const ref = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => setWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
return { ref, width };
}
export function PdfPreview({ blob }: Props) {
const file = useMemo(() => blob, [blob]);
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [loadError, setLoadError] = useState<string | null>(null);
const { ref, width } = useContainerWidth();
const onLoadSuccess = useCallback(({ numPages: n }: { numPages: number }) => {
setNumPages(n);
setPageNumber(1);
setLoadError(null);
}, []);
const onLoadError = useCallback((err: Error) => {
setLoadError(err.message || 'Failed to render PDF');
}, []);
return (
<div className="flex flex-col">
<div ref={ref} className="max-h-[70vh] overflow-auto bg-muted/30">
<Document
file={file}
onLoadSuccess={onLoadSuccess}
onLoadError={onLoadError}
loading={<p className="p-4 text-sm text-muted-foreground">Rendering PDF</p>}
error={null}
noData={null}
>
{numPages > 0 && !loadError && (
<Page
pageNumber={pageNumber}
width={width || undefined}
renderTextLayer
renderAnnotationLayer={false}
/>
)}
</Document>
{loadError && (
<p className="p-4 text-sm text-destructive">{loadError}</p>
)}
</div>
{numPages > 1 && !loadError && (
<div className="flex items-center justify-center gap-3 border-t bg-muted/20 p-2">
<Button
variant="outline"
size="sm"
disabled={pageNumber <= 1}
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
aria-label="Previous page"
>
<ChevronLeft className="size-4" /> Prev
</Button>
<span className="text-xs text-muted-foreground tabular-nums">
Page {pageNumber} of {numPages}
</span>
<Button
variant="outline"
size="sm"
disabled={pageNumber >= numPages}
onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
aria-label="Next page"
>
Next <ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
);
}
export default PdfPreview;
@@ -1,14 +1,45 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const { mockGetSession } = vi.hoisted(() => ({
const { mockGetSession, pdfTriggers } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
pdfTriggers: {
onLoadSuccess: null as ((info: { numPages: number }) => void) | null,
onLoadError: null as ((err: Error) => void) | null,
},
}));
vi.mock('@/lib/supabase', () => ({
supabase: { auth: { getSession: mockGetSession } },
}));
vi.mock('@/lib/pdf-worker', () => ({}));
vi.mock('react-pdf', () => {
type LoadCallback = (info: { numPages: number }) => void;
type ErrorCallback = (err: Error) => void;
const Document = ({
onLoadSuccess,
onLoadError,
children,
}: {
onLoadSuccess?: LoadCallback;
onLoadError?: ErrorCallback;
children?: React.ReactNode;
}) => {
// eslint-disable-next-line react-hooks/immutability -- test stub captures react-pdf callbacks
pdfTriggers.onLoadSuccess = onLoadSuccess ?? null;
// eslint-disable-next-line react-hooks/immutability -- test stub captures react-pdf callbacks
pdfTriggers.onLoadError = onLoadError ?? null;
return <div data-testid="pdf-document">{children}</div>;
};
const Page = ({ pageNumber }: { pageNumber: number }) => (
<div data-testid="pdf-page">page-{pageNumber}</div>
);
return { Document, Page, pdfjs: { GlobalWorkerOptions: {} } };
});
import { ReceiptViewer } from './ReceiptViewer';
const originalFetch = globalThis.fetch;
@@ -17,6 +48,18 @@ const originalRevokeObjectURL = URL.revokeObjectURL;
const blobUrl = 'blob:mock-url';
function triggerPdfLoad(numPages: number) {
act(() => {
pdfTriggers.onLoadSuccess?.({ numPages });
});
}
function triggerPdfError(msg: string) {
act(() => {
pdfTriggers.onLoadError?.(new Error(msg));
});
}
describe('ReceiptViewer', () => {
beforeEach(() => {
mockGetSession.mockReset();
@@ -38,7 +81,7 @@ describe('ReceiptViewer', () => {
it('renders an <img> when the loaded blob is an image', async () => {
mockGetSession.mockResolvedValue({ data: { session: { access_token: 't' } } });
(globalThis.fetch as any).mockResolvedValue({
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'image/jpeg' }),
});
@@ -49,26 +92,202 @@ describe('ReceiptViewer', () => {
expect(img).toHaveAttribute('src', blobUrl);
});
it('renders an iframe for PDF receipts', async () => {
it('renders the inline PDF document for PDF receipts (no iframe)', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as any).mockResolvedValue({
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'application/pdf' }),
});
render(<ReceiptViewer receiptPath="receipts/user-1/statement.pdf" onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByTitle('statement.pdf')).toBeInTheDocument();
expect(await screen.findByTestId('pdf-document')).toBeInTheDocument();
expect(document.querySelector('iframe')).toBeNull();
});
it('paginates through a multi-page PDF and disables nav at bounds', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'application/pdf' }),
});
const user = userEvent.setup();
render(<ReceiptViewer receiptPath="receipts/user-1/multi.pdf" onClose={() => {}} />);
await screen.findByTestId('pdf-document');
triggerPdfLoad(3);
expect(await screen.findByText('Page 1 of 3')).toBeInTheDocument();
expect(screen.getByTestId('pdf-page')).toHaveTextContent('page-1');
expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled();
await user.click(screen.getByRole('button', { name: /next/i }));
expect(await screen.findByText('Page 2 of 3')).toBeInTheDocument();
expect(screen.getByTestId('pdf-page')).toHaveTextContent('page-2');
await user.click(screen.getByRole('button', { name: /next/i }));
expect(await screen.findByText('Page 3 of 3')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
await user.click(screen.getByRole('button', { name: /prev/i }));
expect(await screen.findByText('Page 2 of 3')).toBeInTheDocument();
});
it('hides nav controls for a single-page PDF', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'application/pdf' }),
});
render(<ReceiptViewer receiptPath="receipts/user-1/single.pdf" onClose={() => {}} />);
await screen.findByTestId('pdf-document');
triggerPdfLoad(1);
await waitFor(() => {
expect(screen.queryByRole('button', { name: /next/i })).toBeNull();
});
expect(screen.queryByRole('button', { name: /prev/i })).toBeNull();
expect(screen.queryByText(/page \d+ of \d+/i)).toBeNull();
});
it('shows an inline error and keeps Open / Download / Print enabled when PDF rendering fails', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'application/pdf' }),
});
render(<ReceiptViewer receiptPath="receipts/user-1/corrupt.pdf" onClose={() => {}} />);
await screen.findByTestId('pdf-document');
triggerPdfError('bad xref');
expect(await screen.findByText(/bad xref/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /open in new tab/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /download/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /print/i })).toBeEnabled();
});
it('Print button opens a hidden iframe and triggers its print()', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'application/pdf' }),
});
const user = userEvent.setup();
const printSpy = vi.fn();
const focusSpy = vi.fn();
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = vi
.spyOn(document, 'createElement')
.mockImplementation((tag: string, options?: ElementCreationOptions) => {
const el = originalCreateElement(tag, options) as HTMLElement;
if (tag === 'iframe') {
Object.defineProperty(el, 'contentWindow', {
value: { print: printSpy, focus: focusSpy },
configurable: true,
});
setTimeout(() => el.dispatchEvent(new Event('load')), 0);
}
return el;
});
try {
render(<ReceiptViewer receiptPath="receipts/user-1/single.pdf" onClose={() => {}} />);
await screen.findByRole('button', { name: /print/i });
await user.click(screen.getByRole('button', { name: /print/i }));
await waitFor(() => expect(printSpy).toHaveBeenCalled());
} finally {
createElementSpy.mockRestore();
}
});
it('shows the load error when the file fetch fails', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as any).mockResolvedValue({ ok: false, status: 404 });
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false, status: 404 });
render(<ReceiptViewer receiptPath="receipts/user-1/missing.jpg" onClose={() => {}} />);
expect(await screen.findByText(/failed to load receipt \(404\)/i)).toBeInTheDocument();
});
it('Open-in-new-tab triggers a target=_blank anchor click', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'image/jpeg' }),
});
const user = userEvent.setup();
let capturedAnchor: HTMLAnchorElement | null = null;
const originalCreateElement = document.createElement.bind(document);
const spy = vi
.spyOn(document, 'createElement')
.mockImplementation((tag: string, options?: ElementCreationOptions) => {
const el = originalCreateElement(tag, options) as HTMLElement;
if (tag === 'a') {
capturedAnchor = el as HTMLAnchorElement;
(el as HTMLAnchorElement).click = vi.fn();
}
return el;
});
try {
render(<ReceiptViewer receiptPath="receipts/user-1/abc.jpg" onClose={() => {}} />);
await screen.findByAltText('abc.jpg');
await user.click(screen.getByRole('button', { name: /open in new tab/i }));
expect(capturedAnchor).not.toBeNull();
const a = capturedAnchor as unknown as HTMLAnchorElement;
expect(a.target).toBe('_blank');
expect(a.rel).toBe('noopener');
expect(a.click).toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});
it('Download triggers a download anchor click with the filename', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'image/jpeg' }),
});
const user = userEvent.setup();
let capturedAnchor: HTMLAnchorElement | null = null;
const originalCreateElement = document.createElement.bind(document);
const spy = vi
.spyOn(document, 'createElement')
.mockImplementation((tag: string, options?: ElementCreationOptions) => {
const el = originalCreateElement(tag, options) as HTMLElement;
if (tag === 'a') {
capturedAnchor = el as HTMLAnchorElement;
(el as HTMLAnchorElement).click = vi.fn();
}
return el;
});
try {
render(<ReceiptViewer receiptPath="receipts/user-1/abc.jpg" onClose={() => {}} />);
await screen.findByAltText('abc.jpg');
await user.click(screen.getByRole('button', { name: /^download$/i }));
expect(capturedAnchor).not.toBeNull();
const a = capturedAnchor as unknown as HTMLAnchorElement;
expect(a.download).toBe('abc.jpg');
expect(a.click).toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});
it('shows a fallback message for unknown file types', async () => {
mockGetSession.mockResolvedValue({ data: { session: null } });
(globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'application/octet-stream' }),
});
render(<ReceiptViewer receiptPath="receipts/user-1/unknown.bin" onClose={() => {}} />);
expect(await screen.findByText(/preview not available/i)).toBeInTheDocument();
});
});
@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import { lazy, Suspense, useEffect, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Download, ExternalLink } from 'lucide-react';
import { Download, ExternalLink, Printer } from 'lucide-react';
import { supabase } from '@/lib/supabase';
import { getConfig } from '@/lib/runtime-config';
const PdfPreview = lazy(() => import('./PdfPreview'));
interface Props {
receiptPath: string | null;
onClose: () => void;
@@ -19,6 +21,7 @@ function parseReceiptPath(receiptPath: string) {
}
export function ReceiptViewer({ receiptPath, onClose }: Props) {
const [blob, setBlob] = useState<Blob | null>(null);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -26,6 +29,7 @@ export function ReceiptViewer({ receiptPath, onClose }: Props) {
useEffect(() => {
if (!receiptPath) {
setBlob(null);
setBlobUrl(null);
setError(null);
return;
@@ -46,11 +50,12 @@ export function ReceiptViewer({ receiptPath, onClose }: Props) {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Failed to load receipt (${res.status})`);
const blob = await res.blob();
const fetched = await res.blob();
if (cancelled) return;
createdUrl = URL.createObjectURL(blob);
createdUrl = URL.createObjectURL(fetched);
setBlob(fetched);
setBlobUrl(createdUrl);
setMimeType(blob.type);
setMimeType(fetched.type);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load');
} finally {
@@ -89,6 +94,29 @@ export function ReceiptViewer({ receiptPath, onClose }: Props) {
document.body.removeChild(a);
};
const handlePrint = () => {
if (!blobUrl) return;
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
iframe.src = blobUrl;
iframe.addEventListener('load', () => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
} finally {
setTimeout(() => {
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
}, 1000);
}
});
document.body.appendChild(iframe);
};
return (
<Dialog open={!!receiptPath} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl sm:max-w-3xl">
@@ -102,8 +130,14 @@ export function ReceiptViewer({ receiptPath, onClose }: Props) {
<div className="overflow-hidden rounded-md border">
{isImage ? (
<img src={blobUrl} alt={filename} className="max-h-[70vh] w-full object-contain" />
) : isPdf ? (
<iframe src={blobUrl} title={filename} className="h-[70vh] w-full" />
) : isPdf && blob ? (
<Suspense
fallback={
<p className="p-4 text-sm text-muted-foreground">Loading PDF viewer</p>
}
>
<PdfPreview blob={blob} />
</Suspense>
) : (
<p className="p-4 text-sm text-muted-foreground">
Preview not available for this file type. Use Download below.
@@ -115,6 +149,9 @@ export function ReceiptViewer({ receiptPath, onClose }: Props) {
<Button variant="outline" size="sm" onClick={handleOpenInTab} disabled={!blobUrl}>
<ExternalLink className="mr-1 size-4" /> Open in new tab
</Button>
<Button variant="outline" size="sm" onClick={handlePrint} disabled={!blobUrl}>
<Printer className="mr-1 size-4" /> Print
</Button>
<Button variant="outline" size="sm" onClick={handleDownload} disabled={!blobUrl}>
<Download className="mr-1 size-4" /> Download
</Button>
@@ -0,0 +1,4 @@
import { pdfjs } from 'react-pdf';
import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
@@ -1 +1,10 @@
import '@testing-library/jest-dom/vitest';
if (typeof globalThis.ResizeObserver === 'undefined') {
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
}
+1
View File
@@ -21,6 +21,7 @@ export default defineConfig({
exclude: [
'src/components/ui/**',
'src/test/**',
'src/lib/pdf-worker.ts',
'src/main.tsx',
'src/vite-env.d.ts',
'src/**/*.d.ts',