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
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:
Generated
+207
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "tehriehlbudget-backend",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user