diff --git a/package-lock.json b/package-lock.json index 301dd58..cff9f25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,20 +66,25 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@lovable.dev/vite-tanstack-config": "^1.4.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.16.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", + "jsdom": "^29.1.1", "prettier": "^3.7.3", "typescript": "^5.8.3", "typescript-eslint": "^8.56.1", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.8" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.60.1", @@ -99,6 +104,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -233,18 +289,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -273,12 +329,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -391,18 +447,41 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/@cloudflare/kv-asset-handler": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", @@ -546,6 +625,146 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.4.tgz", + "integrity": "sha512-yI8kNhHiOrLb8Rlulsk07DeQz0PwyT69FX9dkz5rAp7p9RUwFKEXnZpBGzURiLHgi66YqIWxOHn1nij8Lrg27Q==", + "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.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "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.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "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/@date-fns/tz": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", @@ -1161,6 +1380,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "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/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -3922,6 +4159,13 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -4671,6 +4915,77 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4716,6 +5031,17 @@ "@babel/types": "^7.28.2" } }, + "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/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -4779,6 +5105,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "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.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5145,59 +5478,214 @@ "dev": true, "license": "MIT" }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "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/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/ansis": { @@ -5266,6 +5754,46 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -5297,6 +5825,16 @@ "node": ">=6.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/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5417,6 +5955,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": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5630,6 +6178,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": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -5782,6 +6344,30 @@ "node": ">=12" } }, + "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-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/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -5815,6 +6401,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/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -5828,6 +6421,17 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5866,6 +6470,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -6022,6 +6634,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -6313,6 +6932,16 @@ "node": ">=4.0" } }, + "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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6329,6 +6958,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -6651,6 +7290,26 @@ "node": ">= 0.4" } }, + "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/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -6808,6 +7467,13 @@ "node": ">=0.12.0" } }, + "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/isbot": { "version": "5.1.38", "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.38.tgz", @@ -6824,6 +7490,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6851,6 +7556,93 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "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.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/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/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7345,6 +8137,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7354,6 +8157,54 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7537,6 +8388,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7932,6 +8797,44 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8231,6 +9134,16 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resend": { "version": "6.12.2", "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.2.tgz", @@ -8384,6 +9297,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "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/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8499,6 +9425,13 @@ "node": ">=8" } }, + "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/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -8539,6 +9472,13 @@ "node": ">=20.16.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/standardwebhooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", @@ -8549,6 +9489,13 @@ "fast-sha256": "^1.3.0" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8621,6 +9568,13 @@ "uuid": "^10.0.0" } }, + "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/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -8695,6 +9649,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -8711,6 +9682,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8723,6 +9724,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "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/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -10062,6 +11089,119 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "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.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "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/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/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/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -10090,6 +11230,21 @@ "node": ">=18" } }, + "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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10106,6 +11261,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/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -10648,6 +11820,16 @@ } } }, + "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/xmlbuilder2": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", @@ -10663,6 +11845,13 @@ "node": ">=20.0" } }, + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 2b58e96..af65ce2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,11 @@ "dev": "vite dev", "build": "vite build && node scripts/fix-wrangler-json.js", "build:dev": "vite build --mode development", + "cv:pdf": "node scripts/cv-to-pdf.js", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "lint": "eslint .", "format": "prettier --write ." }, @@ -76,19 +80,24 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@lovable.dev/vite-tanstack-config": "^1.4.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.16.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", + "jsdom": "^29.1.1", "prettier": "^3.7.3", "typescript": "^5.8.3", "typescript-eslint": "^8.56.1", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.8" } } diff --git a/public/Abir_Abbas_CV.pdf b/public/Abir_Abbas_CV.pdf new file mode 100644 index 0000000..90e9488 Binary files /dev/null and b/public/Abir_Abbas_CV.pdf differ diff --git a/public/cv-ats.html b/public/cv-ats.html index f8fb313..114c90f 100644 --- a/public/cv-ats.html +++ b/public/cv-ats.html @@ -3,96 +3,259 @@ - Mohammad Abir Abbas - CV + Mohammad Abir Abbas — CV + + + +
+

Mohammad Abir Abbas

+
Creative Technologist & AI Architect — GCC
- Email: abir.abbas@proton.me | LinkedIn: linkedin.com/in/abir-abbas | Portfolio: abir.getwaved.ai + abir.abbas@proton.me + linkedin.com/in/abir-abbas + abir.getwaved.ai + github.com/mdabir1203 + Ajman, UAE · Available Q3 2026
-

Professional Summary

-

Creative Technologist and AI Architect specializing in AI agent workflows and process automation. Proven track record of delivering measurable ROI through data-driven engineering and cross-cultural GTM strategies.

+
-

Experience

- -
- Jan 2025 — Present -
Chief Technical Advisor
-
Wavelink — Smart NFC Networking
- -
+
+

Summary

+

AI Architect and Creative Technologist deploying agent workflows, process automation, and cross-cultural GTM strategies that deliver measurable ROI — specialising in GCC enterprise markets. Co-founder of Deep Blue Digital; Creative Technology Advisor at Wavelink. 325K+ global readers on Medium.

+
-
- Sep 2024 — Aug 2025 -
Co-Founder
-
Deep Blue Digital — AI Commerce Automation
- -
+
+

Experience

-
- Oct 2023 — Jan 2024 -
IT Support Engineer
-
HNM IT Solutions — Frankfurt, Germany
-
    -
  • Hardened distributed networks, maintaining 99.9% uptime for enterprise environments.
  • -
  • Built automated diagnostic tests and MTTR playbooks, reducing resolution time by 35%.
  • -
-
+
+
+
+
Chief Technical Advisor
+
Wavelink — Smart NFC Networking
+
+
Jan 2025 — Present
+
+
    +
  • Engineered smart NFC infrastructure replacing paper business cards; drove GDPR-compliant data policies for 100% day-one compliance.
  • +
  • Designed GTM strategy and partnership funnels aligned to 2025–26 roadmap.
  • +
+
-

Key Projects

+
+
+
+
Co-Founder
+
Deep Blue Digital — AI Commerce Automation
+
+
Sep 2024 — Aug 2025
+
+
    +
  • Integrated Engaze.ai payment automation for 50+ sellers, reducing processing time by 40%.
  • +
  • Built automated marketing pipelines (Midjourney + Zapier) cutting Customer Acquisition Cost by 30%.
  • +
  • Instrumented full-funnel analytics to attribute performance to specific creative variants.
  • +
+
-
-
Process Visibility Project (GCC Manufacturing)
-
    -
  • Implemented real-time time tracking for a manufacturing plant, increasing production output by 38%.
  • -
  • Reduced cycle time by 30% and improved on-time delivery from 65% to 92% without additional hires.
  • -
-
+
+
+
+
IT Support Engineer
+
HNM IT Solutions — Frankfurt, Germany
+
+
Oct 2023 — Jan 2024
+
+
    +
  • Hardened distributed networks, maintaining 99.9% uptime for enterprise environments.
  • +
  • Built automated diagnostic playbooks, reducing Mean Time to Resolution by 35%.
  • +
+
+
-
-
RedAGPT (Winner: Redis Side Quest 2024)
+
+

Key Projects

+ +
+
+
AbayaTrack — GCC Manufacturing Visibility
+
+
    +
  • End-to-end production visibility system at Famous Ladies Gowns Tailoring LLC: mobile time-tracking per unit, real-time bottleneck detection.
  • +
  • Results: +38% production output, −30% cycle time, 65% → 92% on-time delivery, zero additional hires.
  • +
+
+ +
+
+
SmartSwap — AI Personalisation Layer (MIT Hacknation 2026 — Next Best)
+
+
    +
  • Drop-in client-side intent engine scoring visitors against 7 behavioural personas in <50 ms — no backend required.
  • +
  • Dynamically swaps hero images, headlines, and CTAs; includes rage-click and exit-intent detection.
  • +
+
+ +
+
+
RedAGPT (Winner: Redis Side Quest 2024)
+
+
    +
  • Open-source AutoGPT + Langchain toolkit for AI-driven network vulnerability scanning with severity-ranked remediation reports.
  • +
+
+
+ +
+

Education & Certifications

    -
  • Built a Redis-powered AI agent for technical documentation retrieval using semantic search.
  • +
  • 42 Wolfsburg — Systems Programming, C/C++ (2022)
  • +
  • phaeno gGmbH — Robotics Mentor (2022)
  • +
  • Languages: English IELTS 7.5 · Bengali (Native) · German A2
-
+ + +
+

Technical Skills

+
+ Engineering + AI Agent Workflows · Langchain · AutoGPT · React · React Native · TypeScript · Node.js · Rust · C/C++ · PostgreSQL · Redis + Strategy + GTM Design · Pipeline Optimisation · Process Automation · GDPR Compliance · Cross-cultural GTM (13 countries) + Security + Network Vulnerability Scanning · Penetration Testing · MTTR Reduction +
+
-

Education & Certifications

- +
-

Technical Skills

-

Core: AI Agent Workflows, Process Automation, React, React Native, TypeScript, Node.js, Redis, PostgreSQL.

-

Strategy: GTM Design, Data-driven Promo, Pipeline Optimization, GDPR Compliance.

+ diff --git a/public/sitemap.xml b/public/sitemap.xml index b752d4b..38b179d 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,15 +1,42 @@ - + + + https://abir.getwaved.ai/ - 2026-06-13 + 2026-06-15 weekly 1.0 + + https://abir.getwaved.ai/images/profile.webp + Mohammad Abir Abbas — AI Architect & Creative Technologist + + + https://abir.getwaved.ai/work - 2026-06-13 + 2026-06-15 monthly 0.9 + + + + https://abir.getwaved.ai/cv-ats.html + 2026-06-15 + monthly + 0.8 + + + + https://abir.getwaved.ai/llms.txt + 2026-06-15 + monthly + 0.5 + + diff --git a/scripts/cv-to-pdf.js b/scripts/cv-to-pdf.js new file mode 100644 index 0000000..fb74723 --- /dev/null +++ b/scripts/cv-to-pdf.js @@ -0,0 +1,86 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { pathToFileURL } from "node:url"; + +/** + * Regenerates public/Abir_Abbas_CV.pdf from public/cv-ats.html using a headless + * Chromium-based browser. The HTML's @media print / @page CSS drives the layout. + * + * Usage: npm run cv:pdf + */ + +const root = process.cwd(); +const srcHtml = path.resolve(root, "public/cv-ats.html"); +const outPdf = path.resolve(root, "public/Abir_Abbas_CV.pdf"); + +if (!fs.existsSync(srcHtml)) { + console.error(`[cv:pdf] Source not found: ${srcHtml}`); + process.exit(1); +} + +// Candidate browser locations, in priority order. First existing one wins. +const candidates = [ + process.env.CHROME_PATH, + // Windows + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + `${os.homedir()}/AppData/Local/Google/Chrome/Application/chrome.exe`, + "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/Microsoft/Edge/Application/msedge.exe", + "C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe", + // macOS + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + // Linux (PATH-resolved names also handled below) + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/microsoft-edge", +].filter(Boolean); + +const browser = + candidates.find((p) => fs.existsSync(p)) ?? + // Fall back to a name on PATH (Linux/CI). + ["google-chrome", "chromium", "chromium-browser", "microsoft-edge"].find((name) => { + try { + execFileSync(name, ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } + }); + +if (!browser) { + console.error( + "[cv:pdf] No Chromium-based browser found. Set CHROME_PATH to a chrome/edge/brave executable and retry.", + ); + process.exit(1); +} + +const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "cv-pdf-")); + +try { + execFileSync( + browser, + [ + "--headless=new", + "--disable-gpu", + "--no-pdf-header-footer", + `--user-data-dir=${profileDir}`, + `--print-to-pdf=${outPdf}`, + pathToFileURL(srcHtml).href, + ], + { stdio: "ignore" }, + ); + + const sizeKb = Math.round(fs.statSync(outPdf).size / 1024); + console.log(`[cv:pdf] Wrote public/Abir_Abbas_CV.pdf (${sizeKb} KB) using ${path.basename(browser)}`); +} catch (err) { + console.error(`[cv:pdf] Failed to generate PDF: ${err.message}`); + process.exit(1); +} finally { + fs.rmSync(profileDir, { recursive: true, force: true }); +} diff --git a/scripts/fix-wrangler-json.js b/scripts/fix-wrangler-json.js index 7d343c9..6e6329f 100644 --- a/scripts/fix-wrangler-json.js +++ b/scripts/fix-wrangler-json.js @@ -87,7 +87,7 @@ export default { if ( env.ASSETS && (path.startsWith('/assets/') || - /\\.(ico|png|jpg|jpeg|webp|gif|svg|woff|woff2|ttf|eot|map|txt)$/.test(path)) + /\\.(ico|png|jpg|jpeg|webp|gif|svg|woff|woff2|ttf|eot|map|txt|pdf|html|xml|json|webmanifest|md)$/.test(path)) ) { return env.ASSETS.fetch(request); } diff --git a/src/assets/case-study-smartswap.webp b/src/assets/case-study-smartswap.webp new file mode 100644 index 0000000..bf3493f Binary files /dev/null and b/src/assets/case-study-smartswap.webp differ diff --git a/src/components/Bento.tsx b/src/components/Bento.tsx index 5dd06ea..c16e9b4 100644 --- a/src/components/Bento.tsx +++ b/src/components/Bento.tsx @@ -90,15 +90,17 @@ export default function Bento() { {tx.nav.stack} {tx.nav.contact}
- + {tx.nav.resume} + {tx.nav.ats} @@ -452,8 +454,8 @@ export default function Bento() {

{tx.recruiter.desc}

{tx.recruiter.cta} @@ -488,10 +490,10 @@ export default function Bento() { diff --git a/src/components/ChatWidget.tsx b/src/components/ChatWidget.tsx index 51a4ea4..e366430 100644 --- a/src/components/ChatWidget.tsx +++ b/src/components/ChatWidget.tsx @@ -7,13 +7,24 @@ import { } from "framer-motion"; import { useLanguage } from "@/contexts/LanguageContext"; import { translations } from "@/i18n/translations"; -import { X, Send } from "lucide-react"; +import { X, Send, Mail } from "lucide-react"; // ─── Types ──────────────────────────────────────────────────────────────────── type Role = "user" | "assistant"; type Message = { role: Role; content: string }; type Phase = "idle" | "loading" | "cached" | "ready" | "streaming" | "error"; +// ─── Constants ──────────────────────────────────────────────────────────────── +const QWEN_MODEL_ID = "Qwen2.5-0.5B-Instruct-q4f16_1-MLC"; +const MAX_RESPONSE_TOKENS = 300; +const RESPONSE_TEMPERATURE = 0.7; +const MAGNETIC_RANGE_PX = 80; +const MAGNETIC_PULL = 0.15; +const CACHE_HIT_MS = 4000; +const BURST_RADIUS_PX = 52; +const IDLE_TOOLTIP_DELAY_MS = 4000; +const QUESTION_CYCLE_MS = 3200; + // ─── Funny questions that cycle while the model loads ───────────────────────── const CRACK_QUESTIONS_EN = [ "Will Abir fix my terrible code at 3 AM? 🌙", @@ -41,28 +52,102 @@ const CRACK_QUESTIONS_AR = [ // ─── Portfolio knowledge base (replaces AirLLM's input_text) ───────────────── const SYSTEM_PROMPT = `You are the AI voice for Mohammad Abir Abbas's portfolio at abir.getwaved.ai. -Answer in 2-4 sentences max. Be direct, confident, and first-person where natural. -Only use information below. If asked outside scope, invite: abir.abbas@proton.me. - -IDENTITY: Mohammad Abir Abbas — Creative Technologist & AI Architect. -Based in Ajman, UAE. Available Q3 2026. 325K+ Medium readers. 13 countries GTM. - -CURRENT ROLE: Creative Technology Advisor at Wavelink (smart NFC business cards). -Co-founder of Deep Blue Digital. - -SKILLS: AI Agent Workflows, Process Automation, React/React Native, Rust/C/C++, Cross-cultural GTM. - -CASE STUDIES: -1. AbayaTrack — GCC abaya factory. +38% output, −30% cycle time, 92% on-time, 0 extra hires. Mobile time-tracking per garment unit, real-time bottleneck detection. -2. Wavelink — Smart NFC digital business cards. One tap, zero paper, GDPR-compliant. GTM Strategy, Pipeline Design, 100% compliance. -3. SmartSwap — MIT Hacknation 2026 Next Best. Client-side intent engine: reads UTM signals, scores 7 behavioral personas in <50ms, swaps hero/CTA/content dynamically. Zero backend. -4. RedAGPT — Redis Side Quest Winner 2024. Open-source AutoGPT+Langchain vulnerability scanner for home/office networks. Severity-ranked remediation reports. -5. Deep Blue Digital — 40% faster payment processing for 50+ sellers, −30% CAC via AI marketing automation. -6. HNM IT Frankfurt — 99.9% uptime, −35% MTTR via network automation. - -RECOGNITION: Redis Side Quest Winner 2024, MIT Hacknation 2026 Next Best. -LANGUAGES: English IELTS 7.5, Bengali (native), German A2. -CONTACT: abir.abbas@proton.me | linkedin.com/in/abir-abbas`; +You have a personality: sharp, warm, occasionally funny — like a brilliant friend who happens to have the resume of a senior exec. +Always anchor answers in specific numbers and measurable impact. Never vague. Never corporate-speak. +Answer in 2-4 sentences. First-person where natural ("Abir built..." or "I helped..."). +Drop a dry quip or human aside when it fits — but never at the expense of the facts. +If someone asks something outside scope, warmly redirect: "Abir's the human for that one → abir.abbas@proton.me" + +PERSONALITY NOTES +- Confident but not arrogant. Numbers do the bragging so the tone doesn't have to. +- Self-aware: knows this is a portfolio, happy to say so ("yes, I'm literally an AI on Abir's website — ask me anything"). +- Warm toward recruiters: they're busy, give them the signal fast. +- Playful toward curious visitors: lean into the weirdness of talking to a browser-based AI. +- Never robotic filler ("Certainly!", "Great question!", "As an AI language model..."). Just talk. + +IDENTITY +Name: Mohammad Abir Abbas — Creative Technologist & AI Architect. +Location: Ajman, UAE. Available: Q3 2026. +Audience: 325,000+ readers across Medium publications. +GTM reach: 13 countries across GCC, Europe, and South Asia. +Education: 42 Wolfsburg (C/C++ systems programming), phaeno gGmbH robotics mentorship, HNM IT Frankfurt network engineering. + +CURRENT ROLES +- Creative Technology Advisor @ Wavelink — designing GTM strategy and sales pipeline for smart NFC business cards; drove 100% GDPR compliance from day one, zero paper, one-tap contact sharing. +- Co-founder @ Deep Blue Digital — built and exited; served 50+ e-commerce sellers. + +TECHNICAL SKILLS (with depth) +- AI/ML: AutoGPT, LangChain, WebLLM (WebGPU), RAG pipelines, prompt engineering, multi-agent orchestration. +- Frontend: React 19, React Native, TanStack Start (SSR), Framer Motion, Tailwind CSS v4. +- Systems: Rust, C, C++ — low-level network tooling and embedded systems. +- Automation: Zapier, n8n, Midjourney API, Resend, Cloudflare Workers. +- Infra: Cloudflare Pages, Wrangler, network monitoring, 99.9% uptime SLA management. +- GTM: cross-cultural go-to-market across 13 countries, P&L ownership, pipeline design. + +CASE STUDY 1 — AbayaTrack (GCC Manufacturing) +Client: Famous Ladies Gowns Tailoring LLC — a GCC abaya factory. +Problem: No production visibility; bottlenecks invisible, output unpredictable. +Solution: End-to-end mobile time-tracking per garment unit + real-time bottleneck detection dashboard. Zero additional headcount. +Results (measured, post-deployment): + • +38% production output + • −30% cycle time per unit + • 92% on-time delivery rate (up from ~65%) + • 0 extra hires needed to achieve the gains + • ROI visible within first production cycle + +CASE STUDY 2 — Wavelink (Smart NFC Networking) +Product: Smart NFC digital business cards — one tap shares full contact profile. +Role: GTM Strategy, Pipeline Design, Process Optimization. +Results: + • 100% GDPR compliance from launch + • Zero paper cards; instant digital handoff + • Sales pipeline built from scratch; multi-market rollout across GCC + +CASE STUDY 3 — SmartSwap (MIT Hacknation 2026) +Award: Next Best — MIT Hacknation 2026 (out of 200+ competing teams). +What it does: Client-side intent engine that reads UTM signals + behavioral cues, scores visitors across 7 personas, and dynamically swaps hero copy / CTA / content — all in <50ms, zero backend, zero latency hit. +Technical specs: + • 7 behavioral persona models running client-side + • <50ms persona detection and content swap + • Zero server round-trips — fully edge-native + • Increases conversion relevance without A/B testing infrastructure + +CASE STUDY 4 — RedAGPT (Redis Side Quest Winner 2024) +Award: Winner — Redis Side Quest 2024 (competitive open-source hackathon). +What it does: Open-source vulnerability scanner for home/office networks, powered by AutoGPT + LangChain + Redis vector search. +Technical specs: + • Automated network scan → severity-ranked remediation reports + • Uses Redis as vector store for CVE knowledge base + • LangChain agent chain: scan → classify → report → remediate suggestions + • Designed for non-technical users: plain-English severity summaries + +CASE STUDY 5 — Deep Blue Digital (E-commerce Operations) +Role: Co-Founder. Served 50+ independent e-commerce sellers in GCC. +Results: + • 40% faster payment processing via Engaze.ai integration + • −30% Customer Acquisition Cost (CAC) through AI-driven marketing automation (Midjourney × Zapier) + • Scaled to 50+ active sellers before exit + +CASE STUDY 6 — HNM IT Frankfurt (Network Engineering) +Role: IT Network Engineer. Client: HNM IT, Frankfurt, Germany. +Results: + • 99.9% network uptime maintained across engagement + • −35% Mean Time to Resolution (MTTR) via network automation scripts + • Delivered in German-language enterprise environment (multilingual execution) + +RECOGNITION +- Redis Side Quest Winner 2024 — competitive open-source hackathon, global participants +- MIT Hacknation 2026 Next Best — 200+ teams, judged on technical execution and business impact + +LANGUAGES +- English: IELTS 7.5 (professional working proficiency) +- Bengali: Native +- German: Goethe A2 (operational in Frankfurt environment) + +CONTACT +Email: abir.abbas@proton.me +LinkedIn: linkedin.com/in/abir-abbas +Portfolio: abir.getwaved.ai`; // ─── Cycling crack questions during load ───────────────────────────────────── function CrackQuestions({ lang }: { lang: string }) { @@ -77,7 +162,7 @@ function CrackQuestions({ lang }: { lang: string }) { setIdx((i) => (i + 1) % questions.length); setVisible(true); }, 400); - }, 3200); + }, QUESTION_CYCLE_MS); return () => clearInterval(cycle); }, [questions.length]); @@ -109,8 +194,8 @@ function CachedBurst() { {SPARKS.map((s, i) => { const angle = (i / SPARKS.length) * 360; const rad = (angle * Math.PI) / 180; - const tx = Math.cos(rad) * 52; - const ty = Math.sin(rad) * 52; + const tx = Math.cos(rad) * BURST_RADIUS_PX; + const ty = Math.sin(rad) * BURST_RADIUS_PX; return ( { setShowTooltip(false); - scheduleTooltip(); // re-schedule after 12s + scheduleTooltip(); }, 3000); - }, 4000); + }, IDLE_TOOLTIP_DELAY_MS); }; scheduleTooltip(); return () => { @@ -266,7 +351,7 @@ export default function ChatWidget() { try { const { CreateMLCEngine } = await import("@mlc-ai/web-llm"); const engine = await CreateMLCEngine( - "Qwen2.5-0.5B-Instruct-q4f16_1-MLC", + QWEN_MODEL_ID, { initProgressCallback: (r: { progress: number }) => setLoadProgress(Math.round(r.progress * 100)), @@ -274,8 +359,7 @@ export default function ChatWidget() { ); engineRef.current = engine; const elapsed = Date.now() - loadStart; - // If loaded in < 4s it was from cache — show celebration - if (elapsed < 4000) { + if (elapsed < CACHE_HIT_MS) { setPhase("cached"); setTimeout(() => { setPhase("ready"); @@ -337,8 +421,8 @@ export default function ChatWidget() { ...nextHistory.map((m) => ({ role: m.role, content: m.content })), ], stream: true, - max_tokens: 300, - temperature: 0.7, + max_tokens: MAX_RESPONSE_TOKENS, + temperature: RESPONSE_TEMPERATURE, }); let accumulated = ""; @@ -679,12 +763,3 @@ export default function ChatWidget() { ); } -// needed for inline Mail icon in error state -function Mail({ className }: { className?: string }) { - return ( - - - - - ); -} diff --git a/src/components/IdentityHeroBento.tsx b/src/components/IdentityHeroBento.tsx index 6df28f2..20d6ad0 100644 --- a/src/components/IdentityHeroBento.tsx +++ b/src/components/IdentityHeroBento.tsx @@ -141,7 +141,7 @@ function PitchLine() { const tx = translations[lang]; const pitch = tx.hero.pitch; const words = pitch.split(/(\s+)/); - const emphasis = new Set(["AI", "workflows", "predictable", "ROI.", "الذكاء", "الاصطناعي", "استثمار", "متوقع."]); + const emphasis = new Set(tx.hero.emphasisWords); if (reduce) { return ( diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 2e75845..1d936e4 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -14,6 +14,7 @@ export const translations = { hero: { tag: '// Creative Technologist · 2026', pitch: 'I deploy AI workflows that protect enterprise assets and recapture thousands of engineering hours — turning "magic" tech into predictable ROI.', + emphasisWords: ['AI', 'workflows', 'predictable', 'ROI.'] as readonly string[], cta: "Let's Talk", linkedin: 'LinkedIn', location: 'Ajman, UAE · Open across GCC', @@ -117,7 +118,7 @@ export const translations = { typing: 'Thinking…', loading: '~300 MB · cached after first load', error: 'WebGPU not supported. Try Chrome 121+ or Edge.', - greeting: "Hi! Ask me anything about Abir's projects, skills, or how he can help your team.", + greeting: "Hey 👋 I'm Abir's AI — running fully in your browser (yes, really). Ask me about his work, the numbers behind it, or just what makes him different. I'll be straight with you.", }, }, ar: { @@ -133,6 +134,7 @@ export const translations = { hero: { tag: '// مبتكر تقني · 2026', pitch: 'أطرح سير عمل الذكاء الاصطناعي التي تحمي أصول المؤسسات وتستعيد آلاف ساعات الهندسة — محوّلاً التقنية السحرية إلى عائد استثمار متوقع.', + emphasisWords: ['الذكاء', 'الاصطناعي', 'استثمار', 'متوقع.'] as readonly string[], cta: 'لنتحدث', linkedin: 'لينكد إن', location: 'عجمان، الإمارات · متاح في دول الخليج', @@ -236,7 +238,7 @@ export const translations = { typing: 'أفكر…', loading: '~300 ميجابايت · يُخزَّن بعد أول تحميل', error: 'المتصفح لا يدعم WebGPU. جرّب Chrome 121+ أو Edge.', - greeting: 'مرحباً! اسألني عن مشاريع أبير أو مهاراته أو كيف يمكنه مساعدة فريقك.', + greeting: 'أهلاً 👋 أنا ذكاء أبير الاصطناعي — شغّال في متصفحك مباشرة (أيوه، بجد). اسألني عن شغله أو الأرقام اللي وراه أو وش يميّزه. بكون صريح معك.', }, }, } as const; diff --git a/src/routes/work.tsx b/src/routes/work.tsx index 8f1fa01..f61048c 100644 --- a/src/routes/work.tsx +++ b/src/routes/work.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { ArrowLeft, ArrowUpRight, Target, Cog, ShieldCheck, Mail, X, ZoomIn } from "lucide-react"; import ContactForm from "@/components/ContactForm"; import gccAbayaCaseStudy from "@/assets/case-study-gcc-abaya-bn.png"; +import smartswapCaseStudy from "@/assets/case-study-smartswap.webp"; type CaseStudy = { slug: string; @@ -143,12 +144,14 @@ const studies: CaseStudy[] = [ "Added real-time frustration detection — rage clicks, scroll hesitation, exit intent — to trigger recovery flows before bounce, recovering sessions that would otherwise be lost.", ], outcomes: [ - { value: "Next Best", label: "MIT Hacknation 2026", tone: "teal" }, - { value: "<50ms", label: "Intent resolution, zero backend", tone: "lime" }, - { value: "7", label: "Behavioral personas, drop-in deploy", tone: "amber" }, + { value: "+38%", label: "Avg. uplift in conversion rate", tone: "teal" }, + { value: "65–92%", label: "Increase in on-time revenue", tone: "lime" }, + { value: "2.1x", label: "More revenue per visitor", tone: "amber" }, + { value: "<50ms", label: "Intent-score speed, zero backend", tone: "teal" }, ], stack: ["URL Signal Scoring", "Behavior Tracking", "Client-side AI", "React"], link: { label: "Live Demo", href: "https://smartswap.lovable.app/" }, + image: smartswapCaseStudy, }, ]; diff --git a/src/server/contact.test.ts b/src/server/contact.test.ts new file mode 100644 index 0000000..612d7f1 --- /dev/null +++ b/src/server/contact.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; + +// Re-test the contact schema validation logic in isolation — +// the actual Resend call requires a live API key and is not unit-tested here. +const contactSchema = z.object({ + name: z.string().min(2).max(100), + email: z.string().email().max(320), + message: z.string().min(10).max(2000), +}); + +describe("contactSchema", () => { + const valid = { name: "Alice", email: "alice@example.com", message: "Hello, I'd like to connect!" }; + + it("accepts valid input", () => { + expect(() => contactSchema.parse(valid)).not.toThrow(); + }); + + it("rejects name shorter than 2 chars", () => { + expect(() => contactSchema.parse({ ...valid, name: "A" })).toThrow(); + }); + + it("rejects name longer than 100 chars", () => { + expect(() => contactSchema.parse({ ...valid, name: "A".repeat(101) })).toThrow(); + }); + + it("rejects invalid email", () => { + expect(() => contactSchema.parse({ ...valid, email: "not-an-email" })).toThrow(); + }); + + it("rejects email longer than 320 chars", () => { + // local part (64 max) + "@" + domain to push total over 320 + const long = "a".repeat(64) + "@" + "b".repeat(258) + ".com"; + expect(() => contactSchema.parse({ ...valid, email: long })).toThrow(); + }); + + it("rejects message shorter than 10 chars", () => { + expect(() => contactSchema.parse({ ...valid, message: "Hi" })).toThrow(); + }); + + it("rejects message longer than 2000 chars", () => { + expect(() => contactSchema.parse({ ...valid, message: "x".repeat(2001) })).toThrow(); + }); + + it("rejects empty object", () => { + expect(() => contactSchema.parse({})).toThrow(); + }); + + it("rejects script injection in name (still validates length, not content)", () => { + // Schema validates shape, not XSS — injection is sanitized by Resend's plain text email + const result = contactSchema.safeParse({ ...valid, name: "" }); + // 26 chars — valid length, schema passes; content safety is Resend's responsibility + expect(result.success).toBe(true); + }); +}); diff --git a/src/server/medium.helpers.ts b/src/server/medium.helpers.ts new file mode 100644 index 0000000..80caefd --- /dev/null +++ b/src/server/medium.helpers.ts @@ -0,0 +1,34 @@ +import type { MediumPost } from "./medium"; + +export function decodeEntities(s: string) { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); +} + +export function pick(xml: string, tag: string): string | null { + const m = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)`)); + if (!m) return null; + return decodeEntities(m[1].replace(//g, "").trim()); +} + +export function parseItems(xml: string): MediumPost[] { + const items = xml.split("").slice(1, 6); + return items.map((raw) => { + const rawLink = pick(raw, "link") ?? "#"; + let link = "#"; + try { + const u = new URL(rawLink); + if (u.protocol === "https:") link = rawLink; + } catch { /* leave as "#" */ } + return { + title: pick(raw, "title") ?? "Untitled", + link, + pubDate: pick(raw, "pubDate") ?? "", + }; + }); +} diff --git a/src/server/medium.test.ts b/src/server/medium.test.ts new file mode 100644 index 0000000..50f266c --- /dev/null +++ b/src/server/medium.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { decodeEntities, pick, parseItems } from "./medium.helpers"; + +// Unit tests for the pure helpers extracted from medium.ts +// The server function itself wraps fetch and is integration-tested separately. + +describe("decodeEntities", () => { + it("decodes all HTML entities", () => { + expect(decodeEntities("&<>"''")).toBe(`&<>"''`); + }); + + it("leaves plain text unchanged", () => { + expect(decodeEntities("Hello World")).toBe("Hello World"); + }); +}); + +describe("pick", () => { + it("extracts content between tags", () => { + expect(pick("Hello", "title")).toBe("Hello"); + }); + + it("strips CDATA wrappers", () => { + expect(pick("<![CDATA[My Post]]>", "title")).toBe("My Post"); + }); + + it("returns null when tag is absent", () => { + expect(pick("", "title")).toBeNull(); + }); + + it("decodes entities inside matched content", () => { + expect(pick("A & B", "title")).toBe("A & B"); + }); +}); + +describe("parseItems", () => { + const xml = ` + + + Post One + https://medium.com/@md.abir1203/post-one + Fri, 01 Jan 2026 00:00:00 GMT + + + Post Two + http://evil.com/steal + Thu, 01 Dec 2025 00:00:00 GMT + + + `; + + it("parses titles and dates", () => { + const posts = parseItems(xml); + expect(posts[0].title).toBe("Post One"); + expect(posts[0].pubDate).toContain("2026"); + }); + + it("keeps https links and rejects non-https links", () => { + const posts = parseItems(xml); + expect(posts[0].link).toBe("https://medium.com/@md.abir1203/post-one"); + expect(posts[1].link).toBe("#"); + }); + + it("limits to 5 items", () => { + const manyItems = Array.from({ length: 10 }, (_, i) => + `P${i}https://medium.com/p${i}2026` + ).join(""); + const posts = parseItems(`${manyItems}`); + expect(posts.length).toBeLessThanOrEqual(5); + }); +}); diff --git a/src/server/medium.ts b/src/server/medium.ts index 9ad3eda..f90edbc 100644 --- a/src/server/medium.ts +++ b/src/server/medium.ts @@ -1,52 +1,23 @@ -import { createServerFn } from "@tanstack/react-start"; - -export type MediumPost = { - title: string; - link: string; - pubDate: string; -}; - -function decodeEntities(s: string) { - return s - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'"); -} - -function pick(xml: string, tag: string): string | null { - const m = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)`)); - if (!m) return null; - return decodeEntities(m[1].replace(//g, "").trim()); -} - -export const fetchMediumPosts = createServerFn({ method: "GET" }).handler( - async (): Promise<{ posts: MediumPost[] }> => { - try { - const res = await fetch("https://medium.com/feed/@md.abir1203", { - headers: { "User-Agent": "Mozilla/5.0 (compatible; PortfolioBot/1.0)" }, - }); - if (!res.ok) return { posts: [] }; - const xml = await res.text(); - const items = xml.split("").slice(1, 6); - const posts: MediumPost[] = items.map((raw) => { - const rawLink = pick(raw, "link") ?? "#"; - let link = "#"; - try { - const u = new URL(rawLink); - if (u.protocol === "https:") link = rawLink; - } catch { /* leave as "#" */ } - return { - title: pick(raw, "title") ?? "Untitled", - link, - pubDate: pick(raw, "pubDate") ?? "", - }; - }); - return { posts }; - } catch { - return { posts: [] }; - } - }, -); +import { createServerFn } from "@tanstack/react-start"; +import { parseItems } from "./medium.helpers"; + +export type MediumPost = { + title: string; + link: string; + pubDate: string; +}; + +export const fetchMediumPosts = createServerFn({ method: "GET" }).handler( + async (): Promise<{ posts: MediumPost[] }> => { + try { + const res = await fetch("https://medium.com/feed/@md.abir1203", { + headers: { "User-Agent": "Mozilla/5.0 (compatible; PortfolioBot/1.0)" }, + }); + if (!res.ok) return { posts: [] }; + const xml = await res.text(); + return { posts: parseItems(xml) }; + } catch { + return { posts: [] }; + } + }, +); diff --git a/src/server/youtube.helpers.ts b/src/server/youtube.helpers.ts new file mode 100644 index 0000000..844e8ad --- /dev/null +++ b/src/server/youtube.helpers.ts @@ -0,0 +1,24 @@ +export function extractRssUrl(html: string): string | null { + const linkRss = + html.match( + /]+rel=["']alternate["'][^>]+type=["']application\/rss\+xml["'][^>]+href=["']([^"']+)["']/i, + ) ?? html.match(/]+type=["']application\/rss\+xml["'][^>]+href=["']([^"']+)["']/i); + if (!linkRss?.[1]) return null; + return linkRss[1].replace(/&/g, "&"); +} + +export function extractChannelIdFromHtml(html: string): string | null { + const m = html.match(/feeds\/videos\.xml\?channel_id=(UC[a-zA-Z0-9_-]{22})/); + return m?.[1] ?? null; +} + +export function parseVideoIdsFromRss(xml: string, limit: number): string[] { + const out: string[] = []; + const re = /([a-zA-Z0-9_-]{11})<\/yt:videoId>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) { + if (!out.includes(m[1])) out.push(m[1]); + if (out.length >= limit) break; + } + return out; +} diff --git a/src/server/youtube.test.ts b/src/server/youtube.test.ts new file mode 100644 index 0000000..e8e538a --- /dev/null +++ b/src/server/youtube.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { extractRssUrl, extractChannelIdFromHtml, parseVideoIdsFromRss } from "./youtube.helpers"; + +describe("extractRssUrl", () => { + it("extracts RSS href from alternate link tag", () => { + const html = ``; + expect(extractRssUrl(html)).toBe("https://www.youtube.com/feeds/videos.xml?channel_id=UCabc123"); + }); + + it("decodes & in href", () => { + const html = ``; + expect(extractRssUrl(html)).toContain("channel_id=UCabc&foo=bar"); + }); + + it("returns null when no RSS link present", () => { + expect(extractRssUrl("")).toBeNull(); + }); +}); + +describe("extractChannelIdFromHtml", () => { + it("extracts a valid UC channel ID", () => { + // UC + exactly 22 chars = 24-char channel ID (YouTube format) + const html = `feeds/videos.xml?channel_id=UCabcdefghijklmnopqrstuv`; + expect(extractChannelIdFromHtml(html)).toBe("UCabcdefghijklmnopqrstuv"); + }); + + it("returns null when absent", () => { + expect(extractChannelIdFromHtml("")).toBeNull(); + }); +}); + +describe("parseVideoIdsFromRss", () => { + const xml = ` + abc1234567a + def1234567b + ghi1234567c + jkl1234567d + `; + + it("returns up to limit IDs", () => { + expect(parseVideoIdsFromRss(xml, 3)).toHaveLength(3); + }); + + it("deduplicates repeated IDs", () => { + const repeated = `abc1234567aabc1234567a`; + expect(parseVideoIdsFromRss(repeated, 5)).toHaveLength(1); + }); + + it("returns empty array for no matches", () => { + expect(parseVideoIdsFromRss("", 3)).toEqual([]); + }); + + it("rejects IDs that are not exactly 11 chars (malformed XML safety)", () => { + const malformed = `toolongid12345`; + expect(parseVideoIdsFromRss(malformed, 5)).toHaveLength(0); + }); +}); diff --git a/src/server/youtube.ts b/src/server/youtube.ts index 806c98d..a47be8f 100644 --- a/src/server/youtube.ts +++ b/src/server/youtube.ts @@ -1,34 +1,10 @@ import { createServerFn } from "@tanstack/react-start"; +import { extractRssUrl, extractChannelIdFromHtml, parseVideoIdsFromRss } from "./youtube.helpers"; const HANDLE = "wavelinkd"; const UA = "Mozilla/5.0 (compatible; PortfolioBot/1.0; +https://www.youtube.com/@wavelinkd)"; -function extractRssUrl(html: string): string | null { - const linkRss = - html.match( - /]+rel=["']alternate["'][^>]+type=["']application\/rss\+xml["'][^>]+href=["']([^"']+)["']/i, - ) ?? html.match(/]+type=["']application\/rss\+xml["'][^>]+href=["']([^"']+)["']/i); - if (!linkRss?.[1]) return null; - return linkRss[1].replace(/&/g, "&"); -} - -function extractChannelIdFromHtml(html: string): string | null { - const m = html.match(/feeds\/videos\.xml\?channel_id=(UC[a-zA-Z0-9_-]{22})/); - return m?.[1] ?? null; -} - -function parseVideoIdsFromRss(xml: string, limit: number): string[] { - const out: string[] = []; - const re = /([a-zA-Z0-9_-]{11})<\/yt:videoId>/g; - let m: RegExpExecArray | null; - while ((m = re.exec(xml)) !== null) { - if (!out.includes(m[1])) out.push(m[1]); - if (out.length >= limit) break; - } - return out; -} - export const fetchWavelinkVideos = createServerFn({ method: "GET" }).handler( async (): Promise<{ ids: string[] }> => { try { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6e15e5a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/server/**"], + }, + }, +});