diff --git a/eslint.config.mjs b/eslint.config.mjs index 719cea2..42817f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,25 +1,14 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), +export default [ + ...nextCoreWebVitals, { ignores: [ - "node_modules/**", - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + 'node_modules/**', + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', ], }, ]; - -export default eslintConfig; diff --git a/package-lock.json b/package-lock.json index 0951d0a..c48b1f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,9 @@ "country-flag-icons": "^1.6.13", "country-select-js": "^2.1.0", "gsap": "^3.14.2", + "html2canvas": "^1.4.1", "intl-tel-input": "^26.4.1", + "jspdf": "^4.2.0", "lucide-react": "^0.574.0", "motion": "^12.34.1", "next": "^16.1.6", @@ -109,6 +111,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -448,6 +451,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -471,6 +475,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -3355,6 +3360,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -3858,11 +3864,25 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3897,6 +3917,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -3913,6 +3934,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -3964,6 +3992,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4493,6 +4522,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4853,6 +4883,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4953,6 +4992,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5083,23 +5123,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5239,6 +5262,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/country-flag-icons": { "version": "1.6.13", "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.13.tgz", @@ -5343,6 +5378,20 @@ "postcss": "^8.4" } }, + "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/css-prefers-color-scheme": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-11.0.0.tgz", @@ -5399,7 +5448,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -5579,6 +5629,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -5841,6 +5901,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6026,6 +6087,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6350,6 +6412,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-png/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6779,7 +6858,8 @@ "version": "3.14.2", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." + "license": "Standard 'no charge' license: https://gsap.com/standard-license.", + "peer": true }, "node_modules/has-bigints": { "version": "1.1.0", @@ -6910,6 +6990,19 @@ "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", "license": "ISC" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/hyphen": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", @@ -7030,6 +7123,12 @@ "site" ] }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7603,6 +7702,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", + "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8705,6 +8821,13 @@ "node-readable-to-web-readable-stream": "^0.4.2" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8717,6 +8840,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8754,6 +8878,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9485,6 +9610,19 @@ "node": ">=4" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -9580,11 +9718,22 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9594,6 +9743,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9626,6 +9776,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -9783,6 +9934,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -9871,6 +10029,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10233,6 +10401,16 @@ "node": "*" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", @@ -10482,6 +10660,16 @@ "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", "license": "ISC" }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -10502,6 +10690,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, "license": "MIT" }, "node_modules/tailwindcss-animate": { @@ -10533,11 +10722,21 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/three": { "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -10855,6 +11054,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11039,6 +11239,15 @@ "node": ">= 4" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite-compatible-readable-stream": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", @@ -11267,6 +11476,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 364be82..0775587 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "country-flag-icons": "^1.6.13", "country-select-js": "^2.1.0", "gsap": "^3.14.2", + "html2canvas": "^1.4.1", "intl-tel-input": "^26.4.1", + "jspdf": "^4.2.0", "lucide-react": "^0.574.0", "motion": "^12.34.1", "next": "^16.1.6", @@ -65,4 +67,4 @@ "tailwindcss": "^4.1.18", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/public/templates/abo-contract-template.html b/public/templates/abo-contract-template.html new file mode 100644 index 0000000..ea047f9 --- /dev/null +++ b/public/templates/abo-contract-template.html @@ -0,0 +1,681 @@ + + + + + + ABO Vertrag – Profit Planet GmbH + + + +
+
+
+

ABO Vertrag

+

Angebot auf Abschluss eines Kauf- Mietvertrages Kaffee-Service- Kapsel

+
+
+
PROFIT PLANET GMBH
+
Liebenauer Hauptstraße 82c
+
A-8041 Graz
+
FN-649474 i
+
IBAN: AT16 2081 5000 4639 9507
+
Swift/BIC Code: STSPAT2GXXX
+
ATU82089605
+
+
+ +
+
+

Vertrag über automatische Wiederbestellungen (ABO)

+

Bitte alle Felder vollständig ausfüllen und Zutreffendes ankreuzen.

+
+
+
Vertragsnummer: {{contractNumber}}
+
Datum: {{currentDate}}
+
+
+ +

An die

+
+
+
Empfänger
+
{{recipientName}}
+
+
+
Adresse
+
{{recipientAddress}}
+
+
+ +
+
+
Affiliate
+
+
AFFILIATE NAME
+
{{affiliateName}}
+
+
+
+
Client
+
+
CLIENT NAME
+
{{clientName}}
+
+
+
+ +

Lieferadresse

+
+
+
+
+ KUNDE + FIRMA +
+
Vor- und Nachname
{{shippingFullName}}
+
Adresse
{{shippingStreet}}
+
PLZ / Ort
{{shippingPostalCode}}    {{shippingCity}}
+
+
+
Telefonnummer
{{shippingPhone}}
+
Mobil
{{shippingMobile}}
+
E-Mail-Adresse
{{shippingEmail}}
+
+
+
+
+ +
Rechnungsadresse: {{invoiceSameAsShippingMark}} wie Lieferadresse
+
+ +

Rechnungsadresse (falls abweichend)

+
+
+
+
+ FIRMA + KUNDE +
+
Vor- und Nachname
{{invoiceFullName}}
+
Adresse
{{invoiceStreet}}
+
PLZ / Ort
{{invoicePostalCode}}    {{invoiceCity}}
+
+
+
Telefonnummer
{{invoicePhone}}
+
Mobil
{{invoiceMobile}}
+
E-Mail-Adresse
{{invoiceEmail}}
+
+
Bevorzugte Kontaktaufnahme
+
+ Telefon + E-Mail +
+
+
+
+ +
+ FN: {{fnNumber}} + ATU: {{atuNumber}} +
+
+ +

Zutreffendes bitte ankreuzen

+
+
+ + Der Kunde/Käufer tätigt das gegenständliche Rechtsgeschäft als Unternehmer im Sinne des § 1 Abs 1 Z 1 KSchG, das heißt, das Geschäft gehört zum Betrieb seines Unternehmens. +
+
+ + Der Kunde/Käufer tätigt das gegenständliche Rechtsgeschäft als Konsument im Sinne des § 1 Abs 1 Z 2 KSchG. +
+
+ +
+ +

Angebote

+
+

+ Mindestbestellmenge für BIO Kaffee und BIO Tee und BIO Kakao beträgt pro Bestellung jeweils 120 Kapseln. + Preis pro Kapsel € 2,97 inkl. 20% MwSt. Preise und Konditionen gemäß gültigem PROFIT PLANET GMBH Tarif. +

+ + + + + + + + + + + + + + + + + +
TarifPreis pro Kapsel
Customer without abo2.97€
Customer with abo1.77€
+
+ +

Produktauswahl

+
+

Superfood Coffee – 60 Kapseln (bitte gewünschte Sorten ankreuzen / ergänzen)

+ {{selectedProductsHtml}} + +

+ Bei Angabe einer automatischen Wiederbestellung, gemäß den Regelungen in nachstehendem Punkt 3, erhält der Kunde in regelmäßigen Abständen, + BEGINNEND AM (Unterzeichnung des Vertrages) vorstehend eingetragene BIO Kaffee-Teemenge für die Dauer des Vertrages oder bis zum Widerruf der automatischen Wiederbestellung. + Der BIO Kaffee-Tee wird automatisch im Abstand von: +

+ +
+ 1 Monat + 2 Monate + 3 Monate +
+ +

fakturiert und innerhalb von drei bis fünf Werktagen an den Kunden geliefert.

+
+ +

Zahlungsart

+
+
+ Sepa + Kreditkarte + Sofortbanking +
+
+ Bitte senden Sie mir meine Rechnung per E-Mail zu! +
+
+ +
+ + +
+ + diff --git a/src/app/admin/contract-management/components/contractEditor.tsx b/src/app/admin/contract-management/components/contractEditor.tsx index 3db037b..f1512fd 100644 --- a/src/app/admin/contract-management/components/contractEditor.tsx +++ b/src/app/admin/contract-management/components/contractEditor.tsx @@ -19,7 +19,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi const [lang, setLang] = useState<'en' | 'de'>('en'); const [type, setType] = useState<'contract' | 'invoice' | 'other'>('contract'); - const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract'); + const [contractType, setContractType] = useState<'contract' | 'gdpr' | 'abo'>('contract'); const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal'); const [description, setDescription] = useState(''); @@ -55,7 +55,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description` setLang((tpl.lang as any) || 'en'); setType(((tpl.type as any) || 'contract') as 'contract' | 'invoice' | 'other'); - setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr'); + setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr' | 'abo'); setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both'); setEditingMeta({ id: editingTemplateId, @@ -163,6 +163,20 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi return; } + try { + console.info('[ContractEditor] doSave()', { + editingTemplateId: editingTemplateId ?? null, + publish, + name: name.trim(), + type, + contract_type: type === 'contract' ? contractType : null, + lang, + user_type: type === 'invoice' ? 'both' : userType, + descriptionLength: description ? description.length : 0, + htmlLength: html.length, + }); + } catch {} + setSaving(true); setStatusMsg(null); @@ -216,7 +230,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi const save = async (publish: boolean) => { if (publish) { let kind = type === 'contract' - ? (contractType === 'gdpr' ? 'GDPR' : 'Contract') + ? (contractType === 'gdpr' ? 'GDPR' : contractType === 'abo' ? 'ABO' : 'Contract') : type === 'invoice' ? 'Invoice' : 'Other'; @@ -302,12 +316,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi {type === 'contract' && ( )} {type !== 'invoice' && ( diff --git a/src/app/admin/contract-management/components/contractTemplateList.tsx b/src/app/admin/contract-management/components/contractTemplateList.tsx index 7302877..6af3a65 100644 --- a/src/app/admin/contract-management/components/contractTemplateList.tsx +++ b/src/app/admin/contract-management/components/contractTemplateList.tsx @@ -100,7 +100,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) const tpl = items.find((i) => i.id === id); if (tpl) { const kind = tpl.type === 'contract' - ? (tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract') + ? (tpl.contract_type === 'gdpr' ? 'GDPR' : tpl.contract_type === 'abo' ? 'ABO' : 'Contract') : tpl.type === 'invoice' ? 'Invoice' : 'Other'; @@ -172,7 +172,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) )} {c.type === 'contract' && ( - {c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'} + {c.contract_type === 'gdpr' ? 'GDPR' : c.contract_type === 'abo' ? 'ABO' : 'Contract'} )} {c.user_type && c.type !== 'invoice' && ( diff --git a/src/app/admin/contract-management/hooks/useContractManagement.ts b/src/app/admin/contract-management/hooks/useContractManagement.ts index c1f239a..a3d791c 100644 --- a/src/app/admin/contract-management/hooks/useContractManagement.ts +++ b/src/app/admin/contract-management/hooks/useContractManagement.ts @@ -5,7 +5,7 @@ export type DocumentTemplate = { id: string; name: string; type?: string; - contract_type?: 'contract' | 'gdpr' | null | string; + contract_type?: 'contract' | 'gdpr' | 'abo' | null | string; lang?: 'en' | 'de' | string; user_type?: 'personal' | 'company' | 'both' | string; state?: 'active' | 'inactive' | string; @@ -32,6 +32,33 @@ function isFormData(body: any): body is FormData { return typeof FormData !== 'undefined' && body instanceof FormData; } +function safeDescribeBody(body: any) { + if (!body) return null; + if (isFormData(body)) { + const entries: Record = {}; + try { + for (const [k, v] of body.entries()) { + if (typeof File !== 'undefined' && v instanceof File) { + entries[k] = { kind: 'File', name: v.name, type: v.type, size: v.size }; + } else { + // Strings only for our current usage. + entries[k] = v; + } + } + } catch (e: any) { + return { kind: 'FormData', error: e?.message || String(e) }; + } + return { kind: 'FormData', entries }; + } + + if (typeof body === 'string') { + return { kind: 'string', preview: body.slice(0, 500), length: body.length }; + } + + // Avoid dumping arbitrary objects (could be huge / sensitive) + return { kind: typeof body }; +} + export default function useContractManagement() { const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''; const getState = useAuthStore.getState; @@ -57,16 +84,32 @@ export default function useContractManagement() { headers['Content-Type'] = headers['Content-Type'] || 'application/json'; } + const url = `${base}${path}`; + const method = init.method || 'GET'; + // Debug (safe) try { console.debug('[CM] fetch ->', { - url: `${base}${path}`, - method: init.method || 'GET', + url, + method, hasAuth: !!token, - tokenPrefix: token ? `${token.substring(0, 12)}...` : null, }); } catch {} + // EXTRA debug for document-template calls: show what we send (safe metadata only) + if (path.startsWith('/api/document-templates')) { + try { + const safeHeaders = { ...headers } as Record; + if (safeHeaders.Authorization) safeHeaders.Authorization = '[redacted]'; + console.info('[CM][document-templates] request', { + url, + method, + headers: safeHeaders, + body: safeDescribeBody(init.body), + }); + } catch {} + } + // Include cookies + Authorization on all requests const res = await fetch(`${base}${path}`, { credentials: 'include', @@ -113,7 +156,7 @@ export default function useContractManagement() { return {} as T; } }, - [base] + [base, getState] ); // Document templates @@ -154,7 +197,7 @@ export default function useContractManagement() { file: File | Blob; name: string; type: string; - contract_type?: 'contract' | 'gdpr'; + contract_type?: 'contract' | 'gdpr' | 'abo'; lang: 'en' | 'de' | string; description?: string; user_type?: 'personal' | 'company' | 'both'; @@ -171,6 +214,19 @@ export default function useContractManagement() { if (payload.description) fd.append('description', payload.description); fd.append('user_type', (payload.user_type ?? 'both')); + try { + console.info('[CM][document-templates] uploadTemplate()', { + name: payload.name, + type: payload.type, + contract_type: payload.contract_type, + willSendContractType: payload.type === 'contract' && Boolean(payload.contract_type), + lang: payload.lang, + user_type: payload.user_type ?? 'both', + descriptionLength: payload.description ? payload.description.length : 0, + file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null, + }); + } catch {} + return authorizedFetch('/api/document-templates', { method: 'POST', body: fd }); }, [authorizedFetch]); @@ -178,7 +234,7 @@ export default function useContractManagement() { file?: File | Blob; name?: string; type?: string; - contract_type?: 'contract' | 'gdpr'; + contract_type?: 'contract' | 'gdpr' | 'abo'; lang?: 'en' | 'de' | string; description?: string; user_type?: 'personal' | 'company' | 'both'; @@ -205,7 +261,7 @@ export default function useContractManagement() { file: File | Blob; name?: string; type?: string; - contract_type?: 'contract' | 'gdpr'; + contract_type?: 'contract' | 'gdpr' | 'abo'; lang?: 'en' | 'de' | string; description?: string; user_type?: 'personal' | 'company' | 'both'; @@ -224,6 +280,20 @@ export default function useContractManagement() { if (payload.user_type !== undefined) fd.append('user_type', payload.user_type); if (payload.state !== undefined) fd.append('state', payload.state); + try { + console.info('[CM][document-templates] reviseTemplate()', { + id, + name: payload.name, + type: payload.type, + contract_type: payload.contract_type, + lang: payload.lang, + user_type: payload.user_type, + state: payload.state, + descriptionLength: payload.description ? payload.description.length : 0, + file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null, + }); + } catch {} + return authorizedFetch(`/api/document-templates/${id}/revise`, { method: 'POST', body: fd }); }, [authorizedFetch]); diff --git a/src/app/admin/dashboard-management/hooks/useAdminDashboardPlatforms.ts b/src/app/admin/dashboard-management/hooks/useAdminDashboardPlatforms.ts new file mode 100644 index 0000000..73f5eda --- /dev/null +++ b/src/app/admin/dashboard-management/hooks/useAdminDashboardPlatforms.ts @@ -0,0 +1,272 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { authFetch } from '../../../utils/authFetch' +import { + DEFAULT_DASHBOARD_PLATFORMS, + type DashboardPlatform, + type DashboardPlatformColorClass, + type DashboardPlatformIconName +} from '../../../utils/dashboardPlatforms' + +type BackendPlatform = { + id: string | number + title: string + href: string + description?: string | null + icon?: DashboardPlatformIconName | null + color?: DashboardPlatformColorClass | null + state?: boolean + disabled?: boolean + disabledText?: string | null + sortOrder?: number | null +} + +export type PlatformRow = DashboardPlatform & { + _isNew?: boolean +} + +const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '') +const FIXED_ICON: DashboardPlatformIconName = 'LinkIcon' + +function createId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return (crypto as any).randomUUID() + } + return `platform_${Date.now()}_${Math.random().toString(16).slice(2)}` +} + +function isValidHref(href: string): boolean { + const v = href.trim() + if (!v) return false + return v.startsWith('/') || v.startsWith('http://') || v.startsWith('https://') +} + +function toRow(p: BackendPlatform): PlatformRow { + return { + id: String(p.id), + title: typeof p.title === 'string' ? p.title : '', + description: typeof p.description === 'string' ? p.description : '', + href: typeof p.href === 'string' ? p.href : '', + icon: FIXED_ICON, + color: (p.color as DashboardPlatformColorClass) || ('bg-blue-500' as DashboardPlatformColorClass), + isActive: typeof p.state === 'boolean' ? p.state : true, + disabled: typeof p.disabled === 'boolean' ? p.disabled : false, + disabledText: typeof p.disabledText === 'string' ? p.disabledText : undefined + } +} + +function toPayload(p: PlatformRow) { + return { + title: p.title, + href: p.href, + description: p.description ?? '', + icon: FIXED_ICON, + color: p.color, + state: Boolean(p.isActive), + disabled: Boolean(p.disabled), + disabledText: p.disabledText ?? '' + } +} + +function normalizeForCompare(p: PlatformRow) { + return { + title: (p.title || '').trim(), + href: (p.href || '').trim(), + description: (p.description || '').trim(), + icon: FIXED_ICON, + color: p.color, + isActive: Boolean(p.isActive), + disabled: Boolean(p.disabled), + disabledText: (p.disabledText || '').trim() + } +} + +function isChanged(p: PlatformRow, baselineById: Record): boolean { + if (p._isNew) return true + const baseline = baselineById[p.id] + if (!baseline) return true + const a = normalizeForCompare(p) + const b = normalizeForCompare(baseline) + return ( + a.title !== b.title || + a.href !== b.href || + a.description !== b.description || + a.color !== b.color || + a.isActive !== b.isActive || + a.disabled !== b.disabled || + a.disabledText !== b.disabledText + ) +} + +function forceLinkIcon(rows: DashboardPlatform[]): PlatformRow[] { + return rows.map(r => ({ ...r, icon: FIXED_ICON })) as PlatformRow[] +} + +export function useAdminDashboardPlatforms() { + const [platforms, setPlatforms] = useState(forceLinkIcon(DEFAULT_DASHBOARD_PLATFORMS)) + const [baselineById, setBaselineById] = useState>({}) + const [savedAt, setSavedAt] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const hasValidationErrors = useMemo(() => { + return platforms.some(p => !p.title.trim() || !p.href.trim() || !isValidHref(p.href)) + }, [platforms]) + + const reload = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms`, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include' + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + const json = (await res.json().catch(() => null)) as unknown + const list = Array.isArray(json) ? (json as BackendPlatform[]) : [] + const rows = list.map(toRow) + setPlatforms(rows) + setBaselineById(Object.fromEntries(rows.map(r => [r.id, r]))) + } catch (e: any) { + setError(e?.message || 'Failed to load platforms') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void reload() + }, [reload]) + + const addPlatform = useCallback((): string => { + const id = createId() + setPlatforms(prev => [ + ...prev, + { + id, + title: 'New Platform', + description: '', + href: '/dashboard', + icon: FIXED_ICON, + color: 'bg-blue-500' as DashboardPlatformColorClass, + isActive: true, + disabled: false, + _isNew: true + } + ]) + return id + }, []) + + const updatePlatform = useCallback((id: string, patch: Partial) => { + setPlatforms(prev => prev.map(p => (p.id === id ? { ...p, ...patch, icon: FIXED_ICON } : p))) + }, []) + + const removeNewPlatform = useCallback((id: string) => { + setPlatforms(prev => prev.filter(p => p.id !== id)) + }, []) + + const setPlatformState = useCallback(async (platform: PlatformRow, state: boolean) => { + if (platform._isNew) { + updatePlatform(platform.id, { isActive: state }) + return + } + + const prev = platform.isActive + updatePlatform(platform.id, { isActive: state }) + + try { + const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms/${platform.id}/state`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ state }) + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + setBaselineById(prevMap => { + const prevBaseline = prevMap[platform.id] + if (!prevBaseline) return prevMap + return { ...prevMap, [platform.id]: { ...prevBaseline, isActive: state, icon: FIXED_ICON } } + }) + } catch (e: any) { + setError(e?.message || 'Failed to update platform state') + updatePlatform(platform.id, { isActive: prev }) + } + }, [updatePlatform]) + + const save = useCallback(async () => { + setSaving(true) + setError(null) + try { + // 1) Create new platforms + for (const platform of platforms.filter(p => p._isNew)) { + const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + credentials: 'include', + body: JSON.stringify(toPayload(platform)) + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + } + + // 2) Update changed existing platforms + for (const platform of platforms.filter(p => !p._isNew && isChanged(p, baselineById))) { + const res = await authFetch(`${API_BASE}/api/admin/dashboard-platforms/${platform.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + credentials: 'include', + body: JSON.stringify(toPayload(platform)) + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + } + + // 3) Re-fetch list for canonical state/sort + await reload() + setSavedAt(Date.now()) + } catch (e: any) { + setError(e?.message || 'Save failed') + } finally { + setSaving(false) + } + }, [baselineById, platforms, reload]) + + return { + platforms, + loading, + saving, + error, + savedAt, + hasValidationErrors, + addPlatform, + updatePlatform, + removeNewPlatform, + setPlatformState, + save, + isValidHref, + } +} diff --git a/src/app/admin/dashboard-management/page.tsx b/src/app/admin/dashboard-management/page.tsx new file mode 100644 index 0000000..bccb62b --- /dev/null +++ b/src/app/admin/dashboard-management/page.tsx @@ -0,0 +1,259 @@ +'use client' + +import { useState } from 'react' +import PageLayout from '../../components/PageLayout' +import { + DASHBOARD_PLATFORMS_COLOR_OPTIONS, + type DashboardPlatform, + type DashboardPlatformColorClass +} from '../../utils/dashboardPlatforms' +import { PlusIcon, TrashIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import { useAdminDashboardPlatforms, type PlatformRow } from './hooks/useAdminDashboardPlatforms' + +export default function AdminDashboardManagementPage() { + const { + platforms, + loading, + saving, + error, + savedAt, + hasValidationErrors, + addPlatform, + updatePlatform, + removeNewPlatform, + setPlatformState, + save, + isValidHref, + } = useAdminDashboardPlatforms() + + const [openById, setOpenById] = useState>({}) + + const toggleOpen = (id: string) => { + setOpenById(prev => ({ ...prev, [id]: !prev[id] })) + } + + const addAndOpen = () => { + const id = addPlatform() + setOpenById(prev => ({ ...prev, [id]: true })) + } + + const isOpen = (p: PlatformRow) => Boolean(openById[p.id] ?? p._isNew) + + return ( + +
+
+
+
+
+

Dashboard Management

+

+ Manage the “Platforms” cards shown on the user dashboard. +

+
+ +
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {savedAt && ( +
+ Saved at {new Date(savedAt).toLocaleTimeString('de-DE')} +
+ )} + + {hasValidationErrors && ( +
+ Please ensure every platform has a title and a valid link (must start with “/” or “http(s)://”). +
+ )} + +
+ {loading && ( +
+ Loading… +
+ )} + + {!loading && platforms.map(platform => ( +
+
+
+
+
{platform.title}
+
{platform.href}
+
+
+ + + +
+
+ + {isOpen(platform) && ( +
+ + + + + + + + + + +
+ + + +
+ + {platform.disabled && ( + + )} +
+ )} +
+
+ ))} + + {!loading && platforms.length === 0 && ( +
+ No platforms configured. +
+ )} +
+
+
+
+
+ ) +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b085e30..639f8c2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -279,6 +279,24 @@ export default function AdminDashboardPage() { + {/* Dashboard Management */} + + {/* User Management (unchanged) */} + + + {shippingFeesError && ( +
{shippingFeesError}
+ )} + +
+ {([60, 120] as CoffeeShippingFeePieceCount[]).map((pieceCount) => { + const saving = shippingFeeSaving[pieceCount]; + const savedAt = shippingFeeSavedAt[pieceCount]; + const fieldError = shippingFeeFieldError[pieceCount]; + const current = shippingFees.find((r) => r.pieceCount === pieceCount); + const draft = shippingFeeDraft[pieceCount] ?? ''; + + return ( +
+
+
+
+
{pieceCount} pieces
+ {typeof current?.price === 'number' && Number.isFinite(current.price) ? ( +
Current: €{formatPriceDraft(current.price)}
+ ) : null} + {savedAt ? ( +
+ Saved +
+ ) : null} +
+ {fieldError ? ( +
{fieldError}
+ ) : ( +
Enter a price in EUR (≥ 0).
+ )} +
+ +
+
+ + { + const v = e.target.value; + setShippingFeeDraft((prev) => ({ ...prev, [pieceCount]: v })); + setShippingFeeFieldError((prev) => ({ ...prev, [pieceCount]: null })); + }} + placeholder="0.00" + /> +
+ +
+
+
+ ); + })} +
+ +
{loading && (
Loading…
@@ -131,7 +315,7 @@ export default function AdminSubscriptionsPage() {

Delete coffee?

-

You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.

+

You are about to delete the coffee “{deleteTarget.title}”. This action cannot be undone.

+ + {shippingError && ( +
+ Shipping fees could not be loaded: {shippingError} +
+ )}

2. Choose coffees & quantities

@@ -298,10 +347,25 @@ export default function CoffeeAbonnementPage() {
))} + + {/* Shipping */} +
+ Shipping + + {shippingLoading ? ( + 'Loading…' + ) : isFreeShippingSelected ? ( + 'FREE SHIPPING' + ) : ( + `€${selectedShippingFee.toFixed(2)}` + )} + +
+
Total (net) - €{totalPrice.toFixed(2)} + €{totalNetWithShipping.toFixed(2)}
diff --git a/src/app/coffee-abonnements/summary/components/SignaturePad.tsx b/src/app/coffee-abonnements/summary/components/SignaturePad.tsx new file mode 100644 index 0000000..c81c7d3 --- /dev/null +++ b/src/app/coffee-abonnements/summary/components/SignaturePad.tsx @@ -0,0 +1,167 @@ +'use client' + +import React, { useEffect, useRef } from 'react' + +type Props = { + value: string + onChange: (dataUrl: string) => void + className?: string +} + +export default function SignaturePad({ value, onChange, className }: Props) { + const canvasRef = useRef(null) + const isDrawing = useRef(false) + + const getPos = (e: React.MouseEvent | React.TouchEvent) => { + const canvas = canvasRef.current + if (!canvas) return { x: 0, y: 0 } + const rect = canvas.getBoundingClientRect() + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY + + return { x: clientX - rect.left, y: clientY - rect.top } + } + + const setupCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const dpr = window.devicePixelRatio || 1 + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + + const ctx = canvas.getContext('2d') + if (!ctx) return + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.lineWidth = 2 + ctx.lineCap = 'round' + ctx.strokeStyle = '#1C2B4A' + + // If we already have a signature value, redraw it after resize. + if (value) { + const img = new Image() + img.onload = () => { + try { + ctx.clearRect(0, 0, rect.width, rect.height) + ctx.drawImage(img, 0, 0, rect.width, rect.height) + } catch {} + } + img.src = value + } + } + + useEffect(() => { + setupCanvas() + + const onResize = () => setupCanvas() + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // If parent sets a new value (e.g., cleared externally), reflect it. + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const rect = canvas.getBoundingClientRect() + ctx.clearRect(0, 0, rect.width, rect.height) + + if (!value) return + + const img = new Image() + img.onload = () => { + try { + ctx.drawImage(img, 0, 0, rect.width, rect.height) + } catch {} + } + img.src = value + }, [value]) + + const startDrawing = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const { x, y } = getPos(e) + ctx.beginPath() + ctx.moveTo(x, y) + isDrawing.current = true + } + + const draw = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + if (!isDrawing.current) return + + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const { x, y } = getPos(e) + ctx.lineTo(x, y) + ctx.stroke() + } + + const endDrawing = () => { + if (!isDrawing.current) return + isDrawing.current = false + + const canvas = canvasRef.current + if (!canvas) return + + try { + onChange(canvas.toDataURL('image/png')) + } catch { + onChange('') + } + } + + const clear = () => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const rect = canvas.getBoundingClientRect() + ctx.clearRect(0, 0, rect.width, rect.height) + onChange('') + } + + return ( +
+
+

Signature

+ +
+
+ +
+

+ {value ? 'Signature captured.' : 'Draw your signature in the box.'} +

+
+ ) +} diff --git a/src/app/coffee-abonnements/summary/hooks/useAboActiveContractHtml.ts b/src/app/coffee-abonnements/summary/hooks/useAboActiveContractHtml.ts new file mode 100644 index 0000000..cb9cf38 --- /dev/null +++ b/src/app/coffee-abonnements/summary/hooks/useAboActiveContractHtml.ts @@ -0,0 +1,105 @@ +'use client' + +import { useEffect, useState } from 'react' +import { authFetch } from '../../../utils/authFetch' + +const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '') + +export function useAboActiveContractHtml() { + const [html, setHtml] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + + ;(async () => { + setLoading(true) + setError(null) + try { + const url = `${apiBase}/api/contracts/abo/active` + const res = await authFetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + + const ct = res.headers.get('content-type') || '' + const isJson = ct.includes('application/json') + + try { + console.info('[useAboActiveContractHtml] response meta', { + url, + status: res.status, + ok: res.ok, + contentType: ct, + isJson, + }) + } catch {} + + if (isJson) { + const payload: any = await res.json().catch(() => null) + + try { + console.info('[useAboActiveContractHtml] response json keys', payload && typeof payload === 'object' ? Object.keys(payload) : payload) + } catch {} + + if (!res.ok) { + const msg = payload?.message || payload?.error || `Failed to load contract: ${res.status}` + throw new Error(msg) + } + + const foundRaw = payload?.found ?? payload?.data?.found + const found = typeof foundRaw === 'boolean' ? foundRaw : undefined + + const htmlRaw = payload?.html ?? payload?.data?.html + const htmlValue = typeof htmlRaw === 'string' ? htmlRaw : '' + + const isFound = (typeof found === 'boolean') ? found : Boolean(htmlValue) + + try { + console.info('[useAboActiveContractHtml] parsed json', { + success: payload?.success, + found: isFound, + htmlLength: htmlValue ? htmlValue.length : 0, + htmlPreview: htmlValue ? `${htmlValue.slice(0, 200)}${htmlValue.length > 200 ? '…' : ''}` : '', + }) + } catch {} + + if (active) setHtml(isFound && htmlValue ? htmlValue : null) + return + } + + // Fallback: older endpoints returned raw HTML. + const text = await res.text().catch(() => '') + + try { + console.info('[useAboActiveContractHtml] response text preview', { + status: res.status, + ok: res.ok, + textLength: text.length, + textPreview: text ? `${text.slice(0, 200)}${text.length > 200 ? '…' : ''}` : '', + }) + } catch {} + + if (!res.ok) { + throw new Error(text || `Failed to load contract: ${res.status}`) + } + if (active) setHtml(text || null) + } catch (e: any) { + if (active) { + setHtml(null) + setError(e?.message || 'Failed to load contract preview.') + } + } finally { + if (active) setLoading(false) + } + })() + + return () => { + active = false + } + }, []) + + return { html, loading, error } +} diff --git a/src/app/coffee-abonnements/summary/hooks/useAboContractTemplateHtml.ts b/src/app/coffee-abonnements/summary/hooks/useAboContractTemplateHtml.ts new file mode 100644 index 0000000..63fa9b8 --- /dev/null +++ b/src/app/coffee-abonnements/summary/hooks/useAboContractTemplateHtml.ts @@ -0,0 +1,46 @@ +'use client' + +import { useEffect, useState } from 'react' + +export function useAboContractTemplateHtml() { + const [html, setHtml] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + + ;(async () => { + setLoading(true) + setError(null) + try { + const res = await fetch('/templates/abo-contract-template.html', { + method: 'GET', + headers: { Accept: 'text/html' }, + cache: 'no-store', + }) + + const text = await res.text().catch(() => '') + + if (!res.ok) { + throw new Error(text || `Failed to load contract template: ${res.status}`) + } + + if (active) setHtml(text || null) + } catch (e: any) { + if (active) { + setHtml(null) + setError(e?.message || 'Failed to load contract template preview.') + } + } finally { + if (active) setLoading(false) + } + })() + + return () => { + active = false + } + }, []) + + return { html, loading, error } +} diff --git a/src/app/coffee-abonnements/summary/page.tsx b/src/app/coffee-abonnements/summary/page.tsx index 2973494..cf8f307 100644 --- a/src/app/coffee-abonnements/summary/page.tsx +++ b/src/app/coffee-abonnements/summary/page.tsx @@ -1,19 +1,62 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import PageLayout from '../../components/PageLayout'; import { useRouter } from 'next/navigation'; import { useActiveCoffees } from '../hooks/getActiveCoffees'; import { getStandardVatRate, getVatRates } from './hooks/getTaxRate'; import { subscribeAbo } from './hooks/subscribeAbo'; import useAuthStore from '../../store/authStore' +import { useShippingFees } from '../hooks/useShippingFees'; +import { useAboContractTemplateHtml } from './hooks/useAboContractTemplateHtml' +import SignaturePad from './components/SignaturePad' +import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog' + +const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette + +function extractTemplateVariables(templateHtml: string | null | undefined): string[] { + if (!templateHtml) return [] + const vars = new Set() + const re = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g + let match: RegExpExecArray | null + while ((match = re.exec(templateHtml)) !== null) { + if (match[1]) vars.add(match[1]) + } + return Array.from(vars).sort((a, b) => a.localeCompare(b)) +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function hashString(value: string): number { + // djb2 + let hash = 5381 + for (let i = 0; i < value.length; i++) { + hash = ((hash << 5) + hash) ^ value.charCodeAt(i) + } + return hash >>> 0 +} export default function SummaryPage() { const router = useRouter(); const { coffees, loading, error } = useActiveCoffees(); const user = useAuthStore(state => state.user) + const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees(); + const { html: contractHtml, loading: contractLoading, error: contractError } = useAboContractTemplateHtml() + const [isContractPreviewOpen, setIsContractPreviewOpen] = useState(false) + const [contractPdfUrl, setContractPdfUrl] = useState('') + const [contractPdfKey, setContractPdfKey] = useState('') + const [contractPdfLoading, setContractPdfLoading] = useState(false) + const [contractPdfError, setContractPdfError] = useState(null) const [selections, setSelections] = useState>({}); const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120); const [isForSelf, setIsForSelf] = useState(true); + const [signatureDataUrl, setSignatureDataUrl] = useState('') const [form, setForm] = useState({ firstName: '', lastName: '', @@ -34,7 +77,209 @@ export default function SummaryPage() { const [vatRates, setVatRates] = useState<{ code: string; rate: number | null }[]>([]); const [submitError, setSubmitError] = useState(null); const [submitLoading, setSubmitLoading] = useState(false); - const COLORS = ['#1C2B4A', '#233357', '#2A3B66', '#314475', '#3A4F88', '#5B6C9A']; // dark blue palette + const initialCountryRef = useRef(form.country) + + const templateVariableNames = useMemo(() => extractTemplateVariables(contractHtml), [contractHtml]) + const templateVariableNamesKey = useMemo(() => templateVariableNames.join('|'), [templateVariableNames]) + const [contractVariables, setContractVariables] = useState>({}) + + useEffect(() => { + if (!templateVariableNamesKey) return + setContractVariables(prev => { + let changed = false + const next: Record = { ...prev } + for (const name of templateVariableNames) { + if (next[name] === undefined) { + next[name] = '' + changed = true + } + } + return changed ? next : prev + }) + }, [templateVariableNamesKey, templateVariableNames]) + + const populatedContractHtml = useMemo(() => { + if (!contractHtml) return null + // Replace placeholders with escaped user-entered values so placeholders never show. + return contractHtml.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_whole, varName: string) => { + if (varName === 'signatureImage' && !contractVariables[varName] && signatureDataUrl) { + const safeUrl = String(signatureDataUrl) + if (safeUrl.startsWith('data:image/')) { + const src = escapeHtml(safeUrl) + return `Signature` + } + } + + const value = contractVariables[varName] ?? '' + return escapeHtml(String(value)) + }) + }, [contractHtml, contractVariables, signatureDataUrl]) + + const contractPdfCacheKey = useMemo(() => { + if (!populatedContractHtml) return '' + return String(hashString(populatedContractHtml)) + }, [populatedContractHtml]) + + useEffect(() => { + // Cleanup blob URL when it changes or on unmount. + return () => { + if (contractPdfUrl) URL.revokeObjectURL(contractPdfUrl) + } + }, [contractPdfUrl]) + + const closeContractPreview = () => { + setIsContractPreviewOpen(false) + setContractPdfError(null) + setContractPdfLoading(false) + setContractPdfKey('') + if (contractPdfUrl) { + URL.revokeObjectURL(contractPdfUrl) + setContractPdfUrl('') + } + } + + const openContractPreview = async () => { + if (!populatedContractHtml) return + + setIsContractPreviewOpen(true) + setContractPdfError(null) + + // Reuse only if the populated HTML hasn't changed. + if (contractPdfUrl && contractPdfKey === contractPdfCacheKey) return + + if (contractPdfUrl) { + URL.revokeObjectURL(contractPdfUrl) + setContractPdfUrl('') + } + + setContractPdfLoading(true) + try { + const [jsPdfMod, html2canvasMod] = await Promise.all([import('jspdf'), import('html2canvas')]) + const jsPDF: any = (jsPdfMod as any).jsPDF || (jsPdfMod as any).default + const html2canvas: any = (html2canvasMod as any).default || html2canvasMod + + const parser = new DOMParser() + const doc = parser.parseFromString(populatedContractHtml, 'text/html') + const styles = Array.from(doc.querySelectorAll('style')) + .map(s => s.textContent || '') + .join('\n') + const bodyHtml = doc.body?.innerHTML || '' + + const wrapper = document.createElement('div') + wrapper.style.position = 'fixed' + wrapper.style.left = '-10000px' + wrapper.style.top = '0' + wrapper.style.width = '794px' // approx A4 width at 96dpi + wrapper.style.background = '#ffffff' + wrapper.innerHTML = `${bodyHtml}` + document.body.appendChild(wrapper) + + try { + const pdf = new jsPDF({ orientation: 'p', unit: 'pt', format: 'a4' }) + + const pageWidth = pdf.internal.pageSize.getWidth() + const pageHeight = pdf.internal.pageSize.getHeight() + + const marginX = 24 + const marginTop = 24 + const marginBottom = 24 + + const usableWidth = pageWidth - marginX * 2 + const usableHeight = pageHeight - marginTop - marginBottom + + const renderCanvasToPdf = (canvas: HTMLCanvasElement, pageIndex: number) => { + const imgData = canvas.toDataURL('image/png') + const imgWidth = usableWidth + const imgHeight = (canvas.height * imgWidth) / canvas.width + + // If a single chunk is too tall, fall back to slicing within that chunk. + const sliceCount = Math.max(1, Math.ceil(imgHeight / usableHeight)) + for (let i = 0; i < sliceCount; i++) { + const isFirstPageForChunk = i === 0 + const isFirstOverall = pageIndex === 0 && isFirstPageForChunk + if (!isFirstOverall) pdf.addPage() + const y = marginTop - i * usableHeight + pdf.addImage(imgData, 'PNG', marginX, y, imgWidth, imgHeight) + } + } + + // Split at explicit .pageBreak markers (in document order, even when nested) + // to avoid cutting content between pages. + const docRoot = wrapper.querySelector('.doc') as HTMLElement | null + const pageRoot = docRoot ?? wrapper + const breakEls = Array.from(pageRoot.querySelectorAll('.pageBreak')) as HTMLElement[] + + if (breakEls.length === 0) { + const canvas: HTMLCanvasElement = await html2canvas(wrapper, { + scale: Math.min(2, window.devicePixelRatio || 1), + backgroundColor: '#ffffff', + useCORS: true, + }) + renderCanvasToPdf(canvas, 0) + } else { + const range = document.createRange() + range.setStart(pageRoot, 0) + + const fragments: DocumentFragment[] = [] + for (const br of breakEls) { + range.setEndBefore(br) + const frag = range.cloneContents() + if (frag.childNodes.length > 0) fragments.push(frag) + range.setStartAfter(br) + } + range.setEnd(pageRoot, pageRoot.childNodes.length) + const lastFrag = range.cloneContents() + if (lastFrag.childNodes.length > 0) fragments.push(lastFrag) + + if (fragments.length === 0) { + const canvas: HTMLCanvasElement = await html2canvas(wrapper, { + scale: Math.min(2, window.devicePixelRatio || 1), + backgroundColor: '#ffffff', + useCORS: true, + }) + renderCanvasToPdf(canvas, 0) + } else { + for (let pageIndex = 0; pageIndex < fragments.length; pageIndex++) { + const pageWrapper = document.createElement('div') + pageWrapper.style.position = 'fixed' + pageWrapper.style.left = '-10000px' + pageWrapper.style.top = '0' + pageWrapper.style.width = '794px' + pageWrapper.style.background = '#ffffff' + pageWrapper.innerHTML = `` + + const pageDoc = document.createElement('div') + pageDoc.className = docRoot?.className || 'doc' + pageDoc.appendChild(fragments[pageIndex]) + pageWrapper.appendChild(pageDoc) + document.body.appendChild(pageWrapper) + try { + const canvas: HTMLCanvasElement = await html2canvas(pageWrapper, { + scale: Math.min(2, window.devicePixelRatio || 1), + backgroundColor: '#ffffff', + useCORS: true, + }) + renderCanvasToPdf(canvas, pageIndex) + } finally { + document.body.removeChild(pageWrapper) + } + } + } + } + + const blob = pdf.output('blob') as Blob + const url = URL.createObjectURL(blob) + setContractPdfUrl(url) + setContractPdfKey(contractPdfCacheKey) + } finally { + document.body.removeChild(wrapper) + } + } catch (e: any) { + setContractPdfError(e?.message || 'Failed to generate PDF preview.') + } finally { + setContractPdfLoading(false) + } + } useEffect(() => { try { @@ -96,18 +341,19 @@ export default function SummaryPage() { useEffect(() => { let active = true; (async () => { - console.info('[SummaryPage] Loading vat rates (mount). country:', form.country) + const mountCountry = initialCountryRef.current + console.info('[SummaryPage] Loading vat rates (mount). country:', mountCountry) const list = await getVatRates(); if (!active) return; console.info('[SummaryPage] getVatRates result count:', list.length) setVatRates(list); - const upper = form.country.toUpperCase(); + const upper = mountCountry.toUpperCase(); const match = list.find(r => r.code === upper); if (match?.rate != null) { console.info('[SummaryPage] Initial taxRate from list:', match.rate, 'country:', upper) setTaxRate(match.rate); } else { - const rate = await getStandardVatRate(form.country); + const rate = await getStandardVatRate(mountCountry); console.info('[SummaryPage] Fallback taxRate via getStandardVatRate:', rate, 'country:', upper) setTaxRate(rate ?? 0.07); } @@ -138,8 +384,20 @@ export default function SummaryPage() { () => selectedEntries.reduce((sum, e) => sum + (e.quantity / 10) * e.coffee.pricePer10, 0), [selectedEntries] ); + + const shippingFee = useMemo(() => { + const v = feeByPieceCount[selectedPlanCapsules]; + return Number.isFinite(Number(v)) ? Number(v) : 0; + }, [feeByPieceCount, selectedPlanCapsules]); + + const netWithShipping = useMemo( + () => totalPrice + shippingFee, + [totalPrice, shippingFee] + ); + const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]); - const totalWithTax = useMemo(() => totalPrice + taxAmount, [totalPrice, taxRate, taxAmount]); + const taxAmountWithShipping = useMemo(() => netWithShipping * taxRate, [netWithShipping, taxRate]); + const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]); const handleInput = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -432,6 +690,98 @@ export default function SummaryPage() { )} + + {/* Contract preview + signature (frontend only for now) */} +
+

Contract template preview (ABO)

+

+ This is the ABO contract HTML template (populated from the fields below, frontend-only). +

+ + {contractLoading ? ( +
+ Loading contract preview… +
+ ) : contractError ? ( +
+ Contract preview could not be loaded: {contractError} +
+ ) : populatedContractHtml ? ( + <> + {templateVariableNames.length > 0 && ( +
+
Contract variables
+
+ {templateVariableNames.map(varName => ( +
+ + + setContractVariables(prev => ({ ...prev, [varName]: e.target.value })) + } + className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" + /> +
+ ))} +
+
+ )} + + + ) : ( +
+ Contract template is not available. +
+ )} + +
+ +
+
+ + + ABO contract preview (PDF) + + {contractPdfError ? ( +
+ PDF preview could not be generated: {contractPdfError} +
+ ) : contractPdfLoading ? ( +
+ Generating PDF preview… +
+ ) : contractPdfUrl ? ( +
+