From 7cd8748bd1ee25a0551237a7273b35417edb1951 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 26 Aug 2026 16:08:06 +0200 Subject: [PATCH] feat(analytics): report anonymous shopper events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API can record anonymous shopper events, but the storefront sent none, so the funnel had no data before an order existed. Reports four events — page view, product view, add to cart, checkout started — through an AnalyticsService port. No page imports an analytics library, so the backend is a configuration change rather than a code change. Nothing collected identifies a person. The only identifier is a session id from crypto.randomUUID() held in sessionStorage, so it dies with the tab and never spans a visit. Not a cookie and not localStorage: either would be a persistent identifier, which is precisely what drags a shop into needing a consent banner. Query strings are stripped before the request leaves the browser, so an email in a share link cannot reach a request log on the way to an API that would have stripped it anyway. Off by default. Cloning this repository must not start collecting anything, and while disabled the service attaches no router listener and never generates an identifier, so a visitor's browser is left completely untouched. Failures are swallowed and events dropped rather than retried. A shop that breaks because its analytics endpoint is down has been made worse by measuring itself, and retrying into a queue during an incident turns a quiet failure into a loud one. add_to_cart is instrumented in CartService rather than at the button, so every route into the cart is counted and a new one cannot silently skip reporting. checkout_started carries the order id, which is what lets the API verify payment from the orders table instead of taking the browser's word for it. This repository had no test harness: no spec files, no test target and no CI. Adds all three, since the privacy promise is a property of this class and needs something that fails when it stops holding. Closes #2 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 --- .github/workflows/ci.yml | 32 + angular.json | 43 +- package-lock.json | 871 ++++++++++++++++++++++++- package.json | 4 +- src/app/app.config.ts | 2 + src/app/core/analytics.service.spec.ts | 180 +++++ src/app/core/analytics.service.ts | 189 ++++++ src/app/core/cart.service.ts | 7 +- src/app/pages/checkout.component.ts | 5 +- src/app/pages/product.component.ts | 5 +- src/app/storefront.config.ts | 16 + tsconfig.spec.json | 7 + 12 files changed, 1347 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/app/core/analytics.service.spec.ts create mode 100644 src/app/core/analytics.service.ts create mode 100644 tsconfig.spec.json 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"] +}