diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..050b748 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + build: + name: Test and build + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Unit tests + run: npx ng test --watch=false + + - name: Production build + run: npx ng build --configuration production diff --git a/angular.json b/angular.json index 743761d..1427f3b 100644 --- a/angular.json +++ b/angular.json @@ -16,28 +16,57 @@ "tsConfig": "tsconfig.app.json", "outputPath": "dist/storefront", "index": "src/index.html", - "styles": ["src/tailwind.css"], - "assets": [{ "glob": "**/*", "input": "public" }] + "styles": [ + "src/tailwind.css" + ], + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ] }, "configurations": { "production": { "budgets": [ - { "type": "initial", "maximumWarning": "750kB", "maximumError": "1MB" }, - { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "12kB" } + { + "type": "initial", + "maximumWarning": "750kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "8kB", + "maximumError": "12kB" + } ], "outputHashing": "all" }, - "development": { "optimization": false, "sourceMap": true } + "development": { + "optimization": false, + "sourceMap": true + } }, "defaultConfiguration": "production" }, "serve": { "builder": "@angular/build:dev-server", "configurations": { - "production": { "buildTarget": "storefront:build:production" }, - "development": { "buildTarget": "storefront:build:development" } + "production": { + "buildTarget": "storefront:build:production" + }, + "development": { + "buildTarget": "storefront:build:development" + } }, "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.json", + "buildTarget": "::development" + } } } } diff --git a/package-lock.json b/package-lock.json index ebb19d5..6852ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,11 @@ "@angular/cli": "^22.1.4", "@angular/compiler-cli": "^22.1.2", "@tailwindcss/postcss": "^4.3.3", + "jsdom": "^30.0.1", "postcss": "^8.5.6", "tailwindcss": "^4.3.3", - "typescript": "~6.0.3" + "typescript": "~6.0.3", + "vitest": "^4.1.11" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0.0" @@ -442,6 +444,39 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", @@ -718,6 +753,159 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", + "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", @@ -1194,6 +1382,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@harperfast/extended-iterable": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", @@ -3824,6 +4030,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/gensync": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", @@ -3851,6 +4082,146 @@ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3952,6 +4323,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", @@ -3986,6 +4367,16 @@ "node": ">=18.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -4135,6 +4526,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4379,6 +4780,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", @@ -4392,6 +4807,35 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4410,6 +4854,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4605,6 +5056,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4677,6 +5135,16 @@ "dev": true, "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4717,6 +5185,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5058,6 +5536,19 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -5243,6 +5734,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5297,6 +5795,47 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5793,6 +6332,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -6256,6 +6802,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6404,6 +6957,16 @@ } } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -6604,6 +7167,19 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -6770,6 +7346,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6841,6 +7424,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6851,6 +7441,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", @@ -6897,6 +7494,13 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -6918,6 +7522,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6935,6 +7556,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -6945,6 +7596,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6998,6 +7675,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7479,6 +8166,119 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -7500,6 +8300,41 @@ "license": "MIT", "optional": true }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7516,6 +8351,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", @@ -7540,6 +8392,23 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 3e67ac6..387fb94 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,10 @@ "@angular/cli": "^22.1.4", "@angular/compiler-cli": "^22.1.2", "@tailwindcss/postcss": "^4.3.3", + "jsdom": "^30.0.1", "postcss": "^8.5.6", "tailwindcss": "^4.3.3", - "typescript": "~6.0.3" + "typescript": "~6.0.3", + "vitest": "^4.1.11" } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 0edc8c1..e790ba0 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -2,6 +2,7 @@ import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core' import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { routes } from './app.routes'; +import { AnalyticsService } from './core/analytics.service'; import { AuthService } from './core/auth.service'; import { authInterceptor } from './core/auth.interceptor'; @@ -10,5 +11,6 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([authInterceptor])), provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })), provideAppInitializer(() => inject(AuthService).init()), + provideAppInitializer(() => inject(AnalyticsService).init()), ], }; diff --git a/src/app/core/analytics.service.spec.ts b/src/app/core/analytics.service.spec.ts new file mode 100644 index 0000000..f28ea54 --- /dev/null +++ b/src/app/core/analytics.service.spec.ts @@ -0,0 +1,180 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { Subject } from 'rxjs'; + +import { AnalyticsService } from './analytics.service'; +import { storefrontConfig } from '../storefront.config'; + +/** + * The privacy promise and the "never degrade the shop" rule are both properties + * of this class, so they are pinned here. + */ +describe('AnalyticsService', () => { + let sent: Array>; + let service: AnalyticsService; + let originalFetch: typeof globalThis.fetch; + + function configure(enabled: boolean) { + // storefrontConfig is `as const`; tests need to flip the flag. + (storefrontConfig.analytics as { enabled: boolean }).enabled = enabled; + + TestBed.configureTestingModule({ + providers: [ + AnalyticsService, + { provide: Router, useValue: { events: new Subject() } }, + ], + }); + return TestBed.inject(AnalyticsService); + } + + beforeEach(() => { + sent = []; + originalFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, init?: RequestInit) => { + sent.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(null, { status: 202 })); + }) as typeof globalThis.fetch; + sessionStorage.clear(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + (storefrontConfig.analytics as { enabled: boolean }).enabled = false; + }); + + // ------------------------------------------------------------------------- + // Off by default + // ------------------------------------------------------------------------- + + it('is disabled in the shipped configuration', () => { + // Cloning the repository must not start collecting anything. + expect(storefrontConfig.analytics.enabled).toBe(false); + }); + + it('sends nothing at all when disabled', () => { + service = configure(false); + for (let i = 0; i < 50; i++) { + service.track('page_view', { path: '/shop' }); + } + expect(sent.length).toBe(0); + }); + + it('creates no session identifier when disabled', () => { + service = configure(false); + service.track('product_view', { sku: 'X' }); + // Nothing is stored, so a deployment that has not opted in leaves no trace + // in the visitor's browser whatsoever. + expect(sessionStorage.getItem('opentaberna-analytics-session')).toBeNull(); + }); + + // ------------------------------------------------------------------------- + // What is sent + // ------------------------------------------------------------------------- + + it('flushes once a full batch accumulates', () => { + service = configure(true); + for (let i = 0; i < 10; i++) { + service.track('page_view', { path: '/shop' }); + } + expect(sent.length).toBe(1); + expect(sent[0]['events']).toHaveLength(10); + }); + + it('identifies a shopper only by an opaque session id', () => { + service = configure(true); + for (let i = 0; i < 10; i++) { + service.track('product_view', { sku: 'RED-1' }); + } + + const event = (sent[0]['events'] as Array>)[0]; + + // Only fields from the agreed vocabulary reach the wire. Anything outside + // this set would be a field nobody decided to collect. + const allowed = ['event_type', 'occurred_at', 'order_id', 'path', 'session_id', 'sku']; + expect(Object.keys(event).every((key) => allowed.includes(key))).toBe(true); + + expect(event['session_id']).toBeTruthy(); + // No field that could name a person is present, in this event or any other. + const payload = JSON.stringify(sent[0]); + for (const forbidden of ['email', 'customer_id', 'first_name', 'ip', 'user_agent']) { + expect(payload).not.toContain(forbidden); + } + }); + + it('strips query strings before they leave the browser', () => { + service = configure(true); + for (let i = 0; i < 10; i++) { + service.track('page_view', { path: '/shop?email=someone@example.com&t=1' }); + } + + const event = (sent[0]['events'] as Array>)[0]; + expect(event['path']).toBe('/shop'); + expect(JSON.stringify(sent[0])).not.toContain('someone@example.com'); + }); + + it('keeps the session id in sessionStorage, not a cookie or localStorage', () => { + service = configure(true); + service.track('page_view', { path: '/' }); + + // sessionStorage dies with the tab. A cookie would be a persistent + // identifier, which is what requires a consent banner. + expect(sessionStorage.getItem('opentaberna-analytics-session')).toBeTruthy(); + expect(document.cookie).not.toContain('opentaberna-analytics-session'); + }); + + it('reuses one session id across events', () => { + service = configure(true); + for (let i = 0; i < 10; i++) { + service.track('page_view', { path: `/p${i}` }); + } + + const events = sent[0]['events'] as Array>; + const ids = new Set(events.map((e) => e['session_id'])); + expect(ids.size).toBe(1); + }); + + // ------------------------------------------------------------------------- + // Never degrade the shop + // ------------------------------------------------------------------------- + + it('swallows a failing endpoint rather than throwing into the page', () => { + service = configure(true); + globalThis.fetch = (() => Promise.reject(new Error('network down'))) as typeof globalThis.fetch; + + expect(() => { + for (let i = 0; i < 10; i++) { + service.track('page_view', { path: '/shop' }); + } + }).not.toThrow(); + }); + + it('drops events rather than retrying them', () => { + service = configure(true); + let calls = 0; + globalThis.fetch = (() => { + calls++; + return Promise.reject(new Error('down')); + }) as typeof globalThis.fetch; + + for (let i = 0; i < 10; i++) { + service.track('page_view', { path: '/shop' }); + } + + // Retrying into a queue during an incident turns a quiet failure loud. + expect(calls).toBe(1); + }); + + it('survives sessionStorage being unavailable', () => { + service = configure(true); + const original = Storage.prototype.setItem; + Storage.prototype.setItem = () => { + throw new Error('private browsing'); + }; + + try { + expect(() => service.track('page_view', { path: '/shop' })).not.toThrow(); + } finally { + Storage.prototype.setItem = original; + } + }); +}); diff --git a/src/app/core/analytics.service.ts b/src/app/core/analytics.service.ts new file mode 100644 index 0000000..d28ed6d --- /dev/null +++ b/src/app/core/analytics.service.ts @@ -0,0 +1,189 @@ +import { Injectable, inject } from '@angular/core'; +import { Router, NavigationEnd } from '@angular/router'; +import { filter } from 'rxjs/operators'; + +import { storefrontConfig } from '../storefront.config'; + +/** + * The closed set of things the storefront reports. Matches the API's + * vocabulary; anything else is refused with a 422. + */ +export type StorefrontEventType = + | 'page_view' + | 'product_view' + | 'add_to_cart' + | 'checkout_started'; + +interface StorefrontEvent { + session_id: string; + event_type: StorefrontEventType; + path?: string; + sku?: string; + order_id?: string; + occurred_at: string; +} + +const SESSION_KEY = 'opentaberna-analytics-session'; + +/** Events are batched to this size before an eager flush. */ +const BATCH_SIZE = 10; + +/** Idle time before a partial batch is sent anyway, in milliseconds. */ +const FLUSH_DELAY = 3000; + +/** + * Anonymous shopper telemetry. + * + * **A port, not a vendor SDK.** Components call `track()` and know nothing + * about where events go. Swapping the backend is a change to + * `storefrontConfig`, not a change to any page. + * + * **Nothing here identifies a person.** No customer id, no email, no name — the + * only identifier is `session_id`, generated by `crypto.randomUUID()` and held + * in `sessionStorage`, so it dies with the tab and never spans a visit. It is + * not a cookie and not `localStorage`: neither would survive scrutiny as + * "no persistent identifier", and both would drag the shop into needing a + * consent banner. + * + * **It must never degrade the shop.** Every failure is swallowed. A shop that + * breaks because its analytics endpoint is down has been made worse by + * measuring itself, and events are dropped rather than retried — retrying into + * a queue during an incident turns a quiet failure into a loud one. + * + * **Off unless the operator opts in**, via `storefrontConfig.analytics.enabled`. + */ +@Injectable({ providedIn: 'root' }) +export class AnalyticsService { + private readonly router = inject(Router); + + private readonly config = storefrontConfig.analytics; + private queue: StorefrontEvent[] = []; + private timer: ReturnType | null = null; + private sessionId: string | null = null; + + get enabled(): boolean { + return this.config.enabled; + } + + /** + * Start reporting page views. + * + * Called once at bootstrap. Does nothing when disabled, so no listener is + * attached and no identifier is ever generated on a deployment that has not + * opted in. + */ + init(): void { + if (!this.enabled) { + return; + } + + this.router.events + .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)) + .subscribe((event) => this.track('page_view', { path: event.urlAfterRedirects })); + + // A closing tab is the common case for losing a partial batch, and + // visibilitychange fires where unload is unreliable (mobile Safari). + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.flush(true); + } + }); + } + } + + track( + type: StorefrontEventType, + detail: { path?: string; sku?: string; orderId?: string } = {}, + ): void { + if (!this.enabled) { + return; + } + + this.queue.push({ + session_id: this.session(), + event_type: type, + // The API strips query strings too, but sending them at all is a + // needless way to put an email in a request log. + path: detail.path ? detail.path.split(/[?#]/)[0] : undefined, + sku: detail.sku, + order_id: detail.orderId, + occurred_at: new Date().toISOString(), + }); + + if (this.queue.length >= BATCH_SIZE) { + this.flush(); + return; + } + + this.timer ??= setTimeout(() => this.flush(), FLUSH_DELAY); + } + + /** + * Send whatever is queued. + * + * `beacon` uses `navigator.sendBeacon`, which survives the page going away — + * the only way a closing tab's events arrive at all. It cannot set headers, + * so the payload goes as a Blob typed as JSON. + */ + private flush(beacon = false): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (this.queue.length === 0) { + return; + } + + const body = JSON.stringify({ events: this.queue }); + this.queue = []; + + try { + if (beacon && typeof navigator !== 'undefined' && navigator.sendBeacon) { + navigator.sendBeacon( + this.config.endpoint, + new Blob([body], { type: 'application/json' }), + ); + return; + } + + void fetch(this.config.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => undefined); + } catch { + // Deliberately silent. Analytics failing is not the shopper's problem, + // and there is nothing useful a storefront can do about it. + } + } + + /** + * The per-tab anonymous identifier. + * + * Generated lazily, so a deployment with analytics off never creates one. + */ + private session(): string { + if (this.sessionId) { + return this.sessionId; + } + + try { + const stored = sessionStorage.getItem(SESSION_KEY); + if (stored) { + this.sessionId = stored; + return stored; + } + const created = crypto.randomUUID(); + sessionStorage.setItem(SESSION_KEY, created); + this.sessionId = created; + return created; + } catch { + // Private browsing can refuse sessionStorage. An in-memory id still + // groups this page's events, which is better than dropping them. + this.sessionId ??= crypto.randomUUID(); + return this.sessionId; + } + } +} diff --git a/src/app/core/cart.service.ts b/src/app/core/cart.service.ts index 34cdea2..ccf3404 100644 --- a/src/app/core/cart.service.ts +++ b/src/app/core/cart.service.ts @@ -1,16 +1,21 @@ -import { Injectable, computed, signal } from '@angular/core'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { AnalyticsService } from './analytics.service'; import { CartLine, Item } from '../models'; const CART_KEY = 'opentaberna-cart-v2'; @Injectable({ providedIn: 'root' }) export class CartService { + private readonly analytics = inject(AnalyticsService); private readonly linesState = signal(this.restore()); readonly lines = this.linesState.asReadonly(); readonly count = computed(() => this.linesState().reduce((sum, line) => sum + line.quantity, 0)); readonly total = computed(() => this.linesState().reduce((sum, line) => sum + line.item.price.amount * line.quantity, 0)); add(item: Item, quantity = 1): void { + // Instrumented here rather than at the button, so every route into the + // cart is counted and a new one cannot silently skip reporting. + this.analytics.track('add_to_cart', { sku: item.sku }); const lines = [...this.linesState()]; const existing = lines.find(line => line.item.uuid === item.uuid); if (existing) existing.quantity += quantity; else lines.push({ item, quantity }); diff --git a/src/app/pages/checkout.component.ts b/src/app/pages/checkout.component.ts index addbf9d..78017ce 100644 --- a/src/app/pages/checkout.component.ts +++ b/src/app/pages/checkout.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy, ViewChild, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; +import { AnalyticsService } from '../core/analytics.service'; import { ApiService } from '../core/api.service'; import { AuthService } from '../core/auth.service'; import { CartService } from '../core/cart.service'; @@ -31,12 +32,12 @@ declare global { interface Window { Stripe?: (key:string)=>StripeClient; } } ` }) export class CheckoutComponent implements OnDestroy { @ViewChild('paymentElement') paymentElement?:ElementRef; - private readonly api=inject(ApiService);private readonly router=inject(Router);readonly auth=inject(AuthService);readonly cart=inject(CartService);private readonly pending=inject(PendingCheckoutService); + private readonly api=inject(ApiService);private readonly analytics=inject(AnalyticsService);private readonly router=inject(Router);readonly auth=inject(AuthService);readonly cart=inject(CartService);private readonly pending=inject(PendingCheckoutService); readonly customer=signal(null);readonly addresses=signal([]);readonly order=signal(null);readonly loading=signal(true);readonly working=signal(false);readonly paymentReady=signal(false);readonly success=signal(false);readonly error=signal(''); address:AddressInput={street:'',city:'',zip_code:'',country:'DE',is_default:true};private defaultAddress:Address|null=null;private stripe:StripeClient|null=null;private elements:StripeElements|null=null;private element:StripeElement|null=null; constructor(){if(!this.cart.lines().length&&!this.pending.get()){void this.router.navigateByUrl('/cart');return;}this.api.me().subscribe({next:c=>{this.customer.set(c);this.api.addresses().subscribe({next:a=>{this.addresses.set(a);this.defaultAddress=a.find(x=>x.is_default)??a[0]??null;if(this.defaultAddress)this.address={street:this.defaultAddress.street,city:this.defaultAddress.city,zip_code:this.defaultAddress.zip_code,country:this.defaultAddress.country,is_default:true};this.loading.set(false);const saved=this.pending.get();if(saved&&saved.customerId===c.id){this.order.set({id:saved.orderId,customer_id:c.id,currency:'EUR',status:'pending_payment',total_amount:this.cart.total(),created_at:saved.createdAt,updated_at:saved.createdAt});void this.mountPayment(saved.clientSecret);}} ,error:e=>this.fail(e)});},error:e=>this.fail(e)});} ngOnDestroy(){this.element?.unmount()} - startCheckout(){if(!this.validAddress()){this.error.set('Please complete the shipping address.');return;}this.working.set(true);this.error.set('');this.saveAddress().subscribe({next:()=>{const c=this.customer()!;this.api.createOrder(c.id,this.cart.lines().map(l=>({sku:l.item.sku,quantity:l.quantity}))).subscribe({next:o=>{this.order.set(o);this.api.checkout(c.id,o.id).subscribe({next:checkout=>{this.order.set(checkout);const value:PendingCheckout={orderId:o.id,customerId:c.id,clientSecret:checkout.client_secret,createdAt:new Date().toISOString()};this.pending.set(value);void this.mountPayment(checkout.client_secret)},error:e=>this.fail(e)})},error:e=>this.fail(e)})},error:e=>this.fail(e)});} + startCheckout(){if(!this.validAddress()){this.error.set('Please complete the shipping address.');return;}this.working.set(true);this.error.set('');this.saveAddress().subscribe({next:()=>{const c=this.customer()!;this.api.createOrder(c.id,this.cart.lines().map(l=>({sku:l.item.sku,quantity:l.quantity}))).subscribe({next:o=>{this.order.set(o);this.api.checkout(c.id,o.id).subscribe({next:checkout=>{this.order.set(checkout);this.analytics.track('checkout_started',{orderId:o.id});const value:PendingCheckout={orderId:o.id,customerId:c.id,clientSecret:checkout.client_secret,createdAt:new Date().toISOString()};this.pending.set(value);void this.mountPayment(checkout.client_secret)},error:e=>this.fail(e)})},error:e=>this.fail(e)})},error:e=>this.fail(e)});} async pay(){if(!this.stripe||!this.elements)return;this.working.set(true);this.error.set('');const result=await this.stripe.confirmPayment({elements:this.elements,confirmParams:{return_url:`${location.origin}/checkout`},redirect:'if_required'});if(result.error){this.error.set(result.error.message??'Payment was not completed.');this.working.set(false);return;}if(result.paymentIntent?.status==='succeeded'||result.paymentIntent?.status==='processing'){this.pending.clear();this.cart.clear();this.success.set(true);}else this.error.set(`Payment status: ${result.paymentIntent?.status??'unknown'}`);this.working.set(false)} private async mountPayment(clientSecret:string){try{if(!storefrontConfig.stripePublishableKey)throw new Error('Add your Stripe publishable key to src/app/storefront.config.ts.');await this.loadStripe();this.stripe=window.Stripe!(storefrontConfig.stripePublishableKey);this.elements=this.stripe.elements({clientSecret,appearance:{theme:'stripe',variables:{colorPrimary:'#20211e',borderRadius:'10px'}}});this.element=this.elements.create('payment');this.paymentReady.set(true);this.working.set(false);setTimeout(()=>{if(this.paymentElement)this.element!.mount(this.paymentElement.nativeElement)});}catch(e){this.fail(e)}} private loadStripe(){if(window.Stripe)return Promise.resolve();return new Promise((resolve,reject)=>{const existing=document.querySelector('script[src="https://js.stripe.com/v3/"]');if(existing){existing.addEventListener('load',()=>resolve());existing.addEventListener('error',reject);return;}const script=document.createElement('script');script.src='https://js.stripe.com/v3/';script.onload=()=>resolve();script.onerror=()=>reject(new Error('Stripe.js could not be loaded.'));document.head.appendChild(script);});} diff --git a/src/app/pages/product.component.ts b/src/app/pages/product.component.ts index 92d237c..1661eb8 100644 --- a/src/app/pages/product.component.ts +++ b/src/app/pages/product.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/cor import { ActivatedRoute, RouterLink } from '@angular/router'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { ApiService } from '../core/api.service'; +import { AnalyticsService } from '../core/analytics.service'; import { CartService } from '../core/cart.service'; import { errorMessage, money } from '../core/format'; import { Item } from '../models'; @@ -19,7 +20,7 @@ import { Item } from '../models'; } ` }) export class ProductComponent { - private readonly route=inject(ActivatedRoute);private readonly api=inject(ApiService);private readonly sanitizer=inject(DomSanitizer);readonly cart=inject(CartService);readonly item=signal(null);readonly error=signal('');readonly description=signal(''); - constructor(){const id=this.route.snapshot.paramMap.get('id')!;this.api.item(id).subscribe({next:item=>{this.item.set(item);this.description.set(this.sanitizer.bypassSecurityTrustHtml(item.description||item.short_description||''))},error:e=>this.error.set(errorMessage(e))});} + private readonly route=inject(ActivatedRoute);private readonly api=inject(ApiService);private readonly analytics=inject(AnalyticsService);private readonly sanitizer=inject(DomSanitizer);readonly cart=inject(CartService);readonly item=signal(null);readonly error=signal('');readonly description=signal(''); + constructor(){const id=this.route.snapshot.paramMap.get('id')!;this.api.item(id).subscribe({next:item=>{this.item.set(item);this.analytics.track('product_view',{sku:item.sku,path:`/shop/${id}`});this.description.set(this.sanitizer.bypassSecurityTrustHtml(item.description||item.short_description||''))},error:e=>this.error.set(errorMessage(e))});} price(i:Item){return money(i.price.amount,i.price.currency)} moneyValue=money; available(i:Item){return i.inventory.stock_status==='in_stock'||i.inventory.allow_backorder} entries(value:Record){return Object.entries(value)} } diff --git a/src/app/storefront.config.ts b/src/app/storefront.config.ts index 55f930a..95e4f92 100644 --- a/src/app/storefront.config.ts +++ b/src/app/storefront.config.ts @@ -6,4 +6,20 @@ export const storefrontConfig = { clientId: 'opentaberna-store-ui', }, stripePublishableKey: '', + + /** + * Anonymous shopper telemetry. + * + * Off by default: cloning this repository must not start collecting anything. + * Turning it on also requires STOREFRONT_ANALYTICS_ENABLED on the API, which + * otherwise answers the ingest endpoint with a 404. + * + * Nothing collected identifies a person and nothing persists beyond the tab, + * so no consent banner is required. Point `endpoint` elsewhere to use a + * different backend — no page imports an analytics library directly. + */ + analytics: { + enabled: false, + endpoint: '/api/v1/analytics/events', + }, } as const; diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..d5b00dc --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] +}