diff --git a/frontend/package.json b/frontend/package.json index a377f15..6cbf7e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,8 @@ "dependencies": { "@use-funnel/react-router": "^0.0.7", "framer-motion": "^12.42.0", + "html2canvas-pro": "^2.2.0", + "jspdf": "^4.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router": "^8.0.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 09665dd..af062b8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: framer-motion: specifier: ^12.42.0 version: 12.42.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + html2canvas-pro: + specifier: ^2.2.0 + version: 2.2.0 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -914,9 +920,15 @@ packages: '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -925,6 +937,9 @@ packages: '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/eslint-plugin@8.61.1': resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1076,6 +1091,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.10.38: resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} @@ -1096,6 +1115,10 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1112,10 +1135,16 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1135,6 +1164,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + electron-to-chromium@1.5.376: resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} @@ -1231,6 +1263,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1240,6 +1275,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1302,6 +1340,14 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html2canvas-pro@2.2.0: + resolution: {integrity: sha512-Nx9cTLNn/p2vWHgXh1Y5I7OP54UqG8AuJrRP++2JDhQfTejGj0HEd/14ngzB0kLkS/dHcCIUY3KQuZFQeb/RsQ==} + engines: {node: '>=16.0.0'} + + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1314,6 +1360,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + is-core-module@2.16.2: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} @@ -1355,6 +1404,9 @@ packages: engines: {node: '>=6'} hasBin: true + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1507,6 +1559,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1521,6 +1576,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1540,6 +1598,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1559,11 +1620,18 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} hasBin: true + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1611,6 +1679,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -1618,6 +1690,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} @@ -1630,6 +1706,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1686,6 +1765,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1912,8 +1994,7 @@ snapshots: - supports-color optional: true - '@babel/runtime@7.29.7': - optional: true + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': dependencies: @@ -2426,8 +2507,13 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/pako@2.0.4': {} + '@types/prop-types@15.7.15': {} + '@types/raf@3.4.3': + optional: true + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -2437,6 +2523,9 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2635,6 +2724,8 @@ snapshots: balanced-match@4.0.4: {} + base64-arraybuffer@1.0.2: {} + baseline-browser-mapping@2.10.38: {} brace-expansion@5.0.6: @@ -2654,6 +2745,18 @@ snapshots: caniuse-lite@1.0.30001799: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.7 + '@types/raf': 3.4.3 + core-js: 3.49.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + chai@6.2.2: {} commander@2.20.3: @@ -2668,12 +2771,19 @@ snapshots: browserslist: 4.28.2 optional: true + core-js@3.49.0: + optional: true + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + csstype@3.2.3: {} debug@4.4.3: @@ -2684,6 +2794,11 @@ snapshots: detect-libc@2.1.2: {} + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + electron-to-chromium@1.5.376: {} enhanced-resolve@5.21.6: @@ -2823,10 +2938,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.2.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -2879,12 +3002,25 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html2canvas-pro@2.2.0: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + ignore@5.3.2: {} ignore@7.0.5: {} imurmurhash@0.1.4: {} + iobuffer@5.4.0: {} + is-core-module@2.16.2: dependencies: hasown: 2.0.4 @@ -2912,6 +3048,17 @@ snapshots: json5@2.2.3: {} + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.7 + fast-png: 6.4.0 + fflate: 0.8.3 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.11 + html2canvas: 1.4.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3050,6 +3197,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@2.2.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3059,6 +3208,9 @@ snapshots: pathe@2.0.3: {} + performance-now@2.1.0: + optional: true + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -3073,6 +3225,11 @@ snapshots: punycode@2.3.1: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -3090,6 +3247,9 @@ snapshots: dependencies: loose-envify: 1.4.0 + regenerator-runtime@0.13.11: + optional: true + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -3098,6 +3258,9 @@ snapshots: supports-preserve-symlinks-flag: 1.0.0 optional: true + rgbcolor@1.0.1: + optional: true + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -3169,11 +3332,17 @@ snapshots: stackback@0.0.2: {} + stackblur-canvas@2.7.0: + optional: true + std-env@4.1.0: {} supports-preserve-symlinks-flag@1.0.0: optional: true + svg-pathdata@6.0.3: + optional: true + tailwindcss@4.3.1: {} tapable@2.3.3: {} @@ -3186,6 +3355,10 @@ snapshots: source-map-support: 0.5.21 optional: true + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -3234,6 +3407,10 @@ snapshots: dependencies: punycode: 2.3.1 + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 diff --git a/frontend/src/index.css b/frontend/src/index.css index f704e3f..dafcfc8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -57,7 +57,18 @@ textarea { } } -/* 결과 로딩 화면 (스플래시와 동일한 톤) */ +/* PDF 캡처 중에는 framer-motion(whileInView/initial)으로 아직 화면에 들어오지 않아 + opacity:0 · translateY 상태로 남은 영역까지 모두 펼쳐 보이게 강제한다. + (캡처 직전 .pdf-capturing 클래스를 컨테이너에 부여) */ +.pdf-capturing, +.pdf-capturing * { + opacity: 1 !important; + transform: none !important; + height: auto !important; + overflow: visible !important; +} + +/* 결과 로딩 화면 (온보딩 로고와 동일한 톤: 흰 배경 + 마스코트 뒤 컬러 원) */ .result-loading { position: fixed; inset: 0; @@ -66,25 +77,42 @@ textarea { align-items: center; justify-content: center; gap: 24px; - background: var(--color-gold); /* 버튼색 (Toss Blue) */ + background: #ffffff; z-index: 9999; } +.result-loading__logo { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 180px; + height: 180px; +} +.result-loading__halo { + position: absolute; + inset: 0; + border-radius: 50%; + /* 온보딩(LandingPage)과 동일한 컬러 원 배경 */ + background: radial-gradient(circle at 50% 38%, #e8f0ff 0%, #d4e4ff 55%, #c2d8ff 100%); +} .result-loading__mascot { + position: relative; width: 140px; height: 140px; + filter: drop-shadow(0 8px 20px rgba(0, 100, 255, 0.18)); animation: result-loading-bob 1.4s ease-in-out infinite; } .result-loading__spinner { width: 34px; height: 34px; - border: 4px solid rgba(255, 255, 255, 0.45); - border-top-color: #ffffff; + border: 4px solid rgba(0, 100, 255, 0.18); + border-top-color: var(--color-gold); border-radius: 50%; animation: result-loading-spin 0.8s linear infinite; } .result-loading__text { margin: 0; - color: #ffffff; + color: var(--color-muted); font-size: 15px; font-weight: 600; letter-spacing: -0.02em; diff --git a/frontend/src/lib/pdf.ts b/frontend/src/lib/pdf.ts new file mode 100644 index 0000000..950ffb7 --- /dev/null +++ b/frontend/src/lib/pdf.ts @@ -0,0 +1,77 @@ +import html2canvas from "html2canvas-pro"; +import { jsPDF } from "jspdf"; + +// 결과 화면(element) 전체를 한 장으로 캡처해 A4 여러 페이지 PDF로 저장한다. +// window.print() 인쇄 다이얼로그가 아니라 실제 .pdf 파일을 바로 다운로드한다. +export async function saveElementAsPdf(element: HTMLElement, fileName: string) { + // 레티나급 선명도. 너무 키우면 메모리/속도 부담이라 2배로 고정. + const scale = 2; + + const canvas = await html2canvas(element, { + scale, + useCORS: true, + backgroundColor: "#ffffff", + // 캡처에서 제외할 요소(버튼 등): .no-print + ignoreElements: (el) => el.classList?.contains("no-print"), + // 캡처 시점엔 펼쳐진 전체 높이를 잡아야 하므로 실제 콘텐츠 크기를 사용 + windowWidth: element.scrollWidth, + windowHeight: element.scrollHeight, + }); + + const pdf = new jsPDF({ unit: "pt", format: "a4", orientation: "portrait" }); + const pageW = pdf.internal.pageSize.getWidth(); + const pageH = pdf.internal.pageSize.getHeight(); + + // 캔버스를 페이지 너비에 맞춰 스케일했을 때의 전체 높이 + const imgW = pageW; + const imgH = (canvas.height * imgW) / canvas.width; + + if (imgH <= pageH) { + // 한 페이지에 다 들어가는 경우 + pdf.addImage(canvas.toDataURL("image/jpeg", 0.92), "JPEG", 0, 0, imgW, imgH); + } else { + // 여러 페이지로 분할: 페이지당 캔버스 픽셀 높이를 잘라 각 페이지에 그린다. + const pageCanvasH = (pageH * canvas.width) / imgW; // 페이지 1장에 해당하는 캔버스 픽셀 높이 + let renderedH = 0; + let page = 0; + + while (renderedH < canvas.height) { + const sliceH = Math.min(pageCanvasH, canvas.height - renderedH); + + const pageCanvas = document.createElement("canvas"); + pageCanvas.width = canvas.width; + pageCanvas.height = sliceH; + const ctx = pageCanvas.getContext("2d"); + if (!ctx) break; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, pageCanvas.width, pageCanvas.height); + ctx.drawImage( + canvas, + 0, + renderedH, + canvas.width, + sliceH, + 0, + 0, + canvas.width, + sliceH, + ); + + const sliceImgH = (sliceH * imgW) / canvas.width; + if (page > 0) pdf.addPage(); + pdf.addImage( + pageCanvas.toDataURL("image/jpeg", 0.92), + "JPEG", + 0, + 0, + imgW, + sliceImgH, + ); + + renderedH += sliceH; + page += 1; + } + } + + pdf.save(fileName); +} diff --git a/frontend/src/result/ResultPage.tsx b/frontend/src/result/ResultPage.tsx index 348fd86..97e2132 100644 --- a/frontend/src/result/ResultPage.tsx +++ b/frontend/src/result/ResultPage.tsx @@ -9,7 +9,11 @@ import { ResultView } from "./ResultView"; function ResultLoading() { return (
+ {/* 온보딩 로고와 동일한 톤: 흰 배경 + 마스코트 뒤 컬러 원 */}
+
+ 나에게 맞는 절세 전략을 분석하고 있어요…
매년 최대 @@ -376,6 +403,18 @@ export function ResultView({ rec, profile }: { rec: Recommendation; profile: Use ))}