From cb2ff88143ee26492cde41c77a2bc0419a7270c1 Mon Sep 17 00:00:00 2001 From: a-malik-gh Date: Fri, 25 Sep 2026 22:30:40 +0100 Subject: [PATCH 1/4] refactor(store): merge the compare stores into one canonical module There were four compare-related stores, not the three the issue names. compareStore held selection as ids, comparisonStore held whole Property objects under its own persistence key, comparisonHistoryStore held snapshots, and compareStoreCanonical declared compareStore authoritative - but had no importers, so it changed nothing. The split was a live bug, not just untidiness. PropertyCard wrote to both selection stores on every toggle while reading is-selected from one and limit-reached from the other, so the two could disagree. /compare read comparisonStore while ComparisonBar read compareStore, so the bar and the page could show different selections. compareStore is now the single module, with a selection slice and a history slice: - selectedIds stays canonical, because an id is what the share URL and persistence carry and what a card knows about itself. - propertyCache is a best-effort store of the full objects, populated when a caller toggles with a Property. Explicitly not authoritative: a selection restored from storage or a share link has ids but no objects, so getSelectedProperties() drops unresolved ids rather than inventing placeholders. FloatingComparisonBar needs name and price.total, which is why the cache exists at all. - add/remove/toggle accept a Property or a bare id, since both call styles are in use. That is what lets PropertyCard make one call instead of two. Migrated PropertyCard, usePropertyCardData, FloatingComparisonBar, the compare page and the property domain barrel; deleted comparisonStore, comparisonHistoryStore and compareStoreCanonical. Persistence keeps the propchain-compare key so an existing selection survives, with version 2 and a migrate that also folds in history from the old propchain-comparison-history key - users keep their recent comparisons instead of silently losing them. A malformed legacy entry is tolerated rather than failing the upgrade. The two obsolete store suites are replaced by 20 tests over both slices, including that history survives clearCompare, that the cache is pruned with the selection, and that persistence writes one key at version 2. Closes #1090 --- package-lock.json | 1875 +++++++++++++++-- src/app/compare/page.tsx | 14 +- src/components/FloatingComparisonBar.tsx | 9 +- src/components/PropertyCard.tsx | 18 +- .../__tests__/PropertyCard.a11y.test.tsx | 22 +- .../__tests__/PropertyCardContrast.test.tsx | 11 +- .../PropertyListingSemantics.test.tsx | 11 +- src/hooks/usePropertyCardData.ts | 7 +- src/store/__tests__/compareStore.test.ts | 248 ++- .../__tests__/comparisonHistoryStore.test.ts | 94 - src/store/__tests__/comparisonStore.test.ts | 159 -- src/store/compareStore.ts | 209 +- src/store/compareStoreCanonical.ts | 25 - src/store/comparisonHistoryStore.ts | 68 - src/store/comparisonStore.ts | 61 - src/store/domains/property/index.ts | 6 +- 16 files changed, 2084 insertions(+), 753 deletions(-) delete mode 100644 src/store/__tests__/comparisonHistoryStore.test.ts delete mode 100644 src/store/__tests__/comparisonStore.test.ts delete mode 100644 src/store/compareStoreCanonical.ts delete mode 100644 src/store/comparisonHistoryStore.ts delete mode 100644 src/store/comparisonStore.ts diff --git a/package-lock.json b/package-lock.json index cf087f68..7818b12a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,6 @@ "react-leaflet-cluster": "^4.1.3", "react-resizable-panels": "^4.4.1", "recharts": "^2.15.4", - "redis": "^4.6.10", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", @@ -86,8 +85,11 @@ "devDependencies": { "@axe-core/playwright": "^4.11.2", "@chromatic-com/storybook": "^5.1.1", + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", "@cypress/react": "^9.0.2", "@playwright/test": "^1.48.0", + "@size-limit/file": "^11.1.6", "@storybook/addon-a11y": "^10.3.3", "@storybook/addon-docs": "^10.3.3", "@storybook/addon-onboarding": "^10.3.3", @@ -110,6 +112,7 @@ "@typescript-eslint/parser": "^8.46.1", "@vitest/browser-playwright": "^4.1.2", "@vitest/coverage-v8": "^4.1.2", + "@wagmi/cli": "^2.1.15", "autoprefixer": "^10.4.21", "cypress": "^15.14.1", "eslint": "^9.38.0", @@ -122,6 +125,7 @@ "playwright": "^1.58.2", "plop": "^4.0.0", "postcss": "^8.5.3", + "size-limit": "^11.1.6", "storybook": "^10.3.3", "tailwindcss": "^4.1.4", "ts-node": "^10.9.2", @@ -197,6 +201,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -779,6 +784,7 @@ "resolved": "https://registry.npmjs.org/@coinbase/wallet-sdk/-/wallet-sdk-4.3.7.tgz", "integrity": "sha512-z6e5XDw6EF06RqkeyEa+qD0dZ2ZbLci99vx3zwDY//XO8X7166tqKJrR2XlQnzVmtcUuJtCd5fCvr9Cu6zzX7w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@noble/hashes": "^1.4.0", "clsx": "^1.2.1", @@ -796,6 +802,425 @@ "node": ">=6" } }, + "node_modules/@commitlint/cli": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", + "integrity": "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^19.8.1", + "@commitlint/lint": "^19.8.1", + "@commitlint/load": "^19.8.1", + "@commitlint/read": "^19.8.1", + "@commitlint/types": "^19.8.1", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", + "integrity": "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.8.1.tgz", + "integrity": "sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@commitlint/ensure": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.8.1.tgz", + "integrity": "sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.8.1.tgz", + "integrity": "sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.8.1.tgz", + "integrity": "sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.1.tgz", + "integrity": "sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.8.1.tgz", + "integrity": "sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^19.8.1", + "@commitlint/parse": "^19.8.1", + "@commitlint/rules": "^19.8.1", + "@commitlint/types": "^19.8.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.8.1.tgz", + "integrity": "sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.8.1", + "@commitlint/execute-rule": "^19.8.1", + "@commitlint/resolve-extends": "^19.8.1", + "@commitlint/types": "^19.8.1", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^6.1.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@commitlint/message": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", + "integrity": "sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.8.1.tgz", + "integrity": "sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.8.1.tgz", + "integrity": "sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^19.8.1", + "@commitlint/types": "^19.8.1", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.8.1.tgz", + "integrity": "sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.8.1", + "@commitlint/types": "^19.8.1", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@commitlint/rules": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.8.1.tgz", + "integrity": "sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^19.8.1", + "@commitlint/message": "^19.8.1", + "@commitlint/to-lines": "^19.8.1", + "@commitlint/types": "^19.8.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.8.1.tgz", + "integrity": "sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.8.1.tgz", + "integrity": "sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level/node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/top-level/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@commitlint/top-level/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@commitlint/types": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.8.1.tgz", + "integrity": "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -3173,6 +3598,7 @@ "resolved": "https://registry.npmjs.org/@metamask/sdk/-/sdk-0.33.1.tgz", "integrity": "sha512-1mcOQVGr9rSrVcbKPNVzbZ8eCl1K0FATsYH3WJ/MH4WcZDWGECWrXJPNMZoEAkLxWiMe8jOQBumg2pmcDa9zpQ==", "deprecated": "No longer maintained, superseded by https://docs.metamask.io/metamask-connect", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", "@metamask/onboarding": "^1.0.1", @@ -3488,6 +3914,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.0.tgz", "integrity": "sha512-YGdEUzYEd+82jeaVbSKKVp1jFZb8LwaNMIIzHFkihGvYdd/KKAr7KaJHdEdSYGredE3ssSravXIa0Jxg28Sv5w==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3621,6 +4048,7 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -5252,101 +5680,28 @@ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", "license": "Hippocratic-2.1", + "peer": true, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, - "node_modules/@redis/bloom": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", - "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/@redis/client": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", - "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "cluster-key-slot": "1.1.2", - "generic-pool": "3.9.0", - "yallist": "4.0.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14" - } - }, - "node_modules/@redis/client/node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@redis/client/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/@redis/graph": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", - "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/@redis/json": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", - "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/@redis/search": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", - "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/@redis/time-series": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", - "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", - "license": "MIT", - "peerDependencies": { - "@redis/client": "^1.0.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-darwin-arm64": { @@ -5555,29 +5910,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -5796,6 +6128,19 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@size-limit/file": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-11.2.0.tgz", + "integrity": "sha512-OZHE3putEkQ/fgzz3Tp/0hSmfVo3wyTpOJSRNm6AmcwX4Nm9YtTfbQQ/hZRwbBFR23S7x2Sd9EbqYzngKwbRoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "size-limit": "11.2.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -6374,6 +6719,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.2.tgz", "integrity": "sha512-HzzOC7xgSfGGzZ1gTsFZqYz6rxGg3tYF77nTPctin+wEYYLNMP7LjwPVFALEGNdjxkHvcewh1EM5ywixeukS4w==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -6394,6 +6740,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.2.tgz", "integrity": "sha512-MvvzPcurtzVh4EcbsTfI1BL5GOfdi1S0dk/qhigEghW07MvcHUl/dhfc1FT8hPEquuMtUC+IIAxC0bdmSp/7kA==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.100.2" }, @@ -6455,6 +6802,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -6659,6 +7007,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", + "integrity": "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -6941,6 +7299,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -6990,6 +7349,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7000,6 +7360,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7152,6 +7513,7 @@ "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", @@ -7734,6 +8096,7 @@ "integrity": "sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.5", @@ -7757,6 +8120,7 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -7781,6 +8145,7 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -7901,82 +8266,635 @@ } } }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/@vitest/mocker/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/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@wagmi/cli": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@wagmi/cli/-/cli-2.10.0.tgz", + "integrity": "sha512-2tYt6Bp1q26mWexH+XE6dMpPB5/Gp/3OVtE2SeeJ/gNHKLZmVF/TuoZR75mpJKTpofyvpz/fnuMCkUxzbc/kRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "abitype": "^1.1.1", + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "change-case": "^5.4.4", + "chokidar": "4.0.1", + "dedent": "^0.7.0", + "dotenv": "^16.3.1", + "dotenv-expand": "^10.0.0", + "esbuild": "~0.25.4", + "escalade": "3.2.0", + "fdir": "^6.1.1", + "nanospinner": "1.2.2", + "pathe": "^1.1.2", + "picocolors": "^1.0.0", + "picomatch": "^3.0.0", + "prettier": "^3.0.3", + "viem": "2.x", + "zod": "^4.1.11" + }, + "bin": { + "wagmi": "dist/esm/cli.js" + }, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.7.3" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@wagmi/cli/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "node_modules/@wagmi/cli/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "node_modules/@wagmi/cli/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.5", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "node_modules/@wagmi/cli/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "node_modules/@wagmi/cli/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "node_modules/@wagmi/cli/node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wagmi/cli/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/@wagmi/cli/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wagmi/cli/node_modules/picomatch": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.2.tgz", + "integrity": "sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/@wagmi/connectors": { @@ -8031,6 +8949,7 @@ "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-3.4.0.tgz", "integrity": "sha512-EU5gDsUp5t7+cuLv12/L8hfyWfCIKsBNiiBqpOqxZJxvAcAiQk4xFe2jMgaQPqApc3Omvxrk032M8AQ4N0cQeg==", "license": "MIT", + "peer": true, "dependencies": { "eventemitter3": "5.0.1", "mipd": "0.0.7", @@ -8459,6 +9378,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8721,6 +9641,13 @@ "node": ">=0.10.0" } }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", @@ -9472,6 +10399,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -9625,6 +10553,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes-iec": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz", + "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -9837,6 +10801,22 @@ "functional-red-black-tree": "^1.0.1" } }, + "node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chromatic": { "version": "13.3.5", "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.5.tgz", @@ -10163,6 +11143,17 @@ "node": ">=4.0.0" } }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -10170,6 +11161,51 @@ "dev": true, "license": "MIT" }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -10236,6 +11272,52 @@ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -10309,6 +11391,7 @@ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", "license": "MIT", + "peer": true, "dependencies": { "node-fetch": "^2.7.0" } @@ -10385,6 +11468,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@cypress/request": "^3.0.10", "@cypress/xvfb": "^1.2.4", @@ -10661,6 +11745,19 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -11118,14 +12215,50 @@ "node": ">=12" } }, - "node_modules/dompurify": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", - "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" + "node_modules/dompurify": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" } }, "node_modules/dunder-proto": { @@ -11157,6 +12290,7 @@ "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", "license": "MIT", + "peer": true, "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", @@ -11212,7 +12346,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -11395,6 +12530,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/errno": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", @@ -11605,6 +12750,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11690,6 +12836,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11924,6 +13071,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12956,7 +14104,8 @@ "version": "6.4.9", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eventemitter3": { "version": "5.0.4", @@ -13245,6 +14394,23 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -13665,15 +14831,6 @@ "node": ">= 0.4" } }, - "node_modules/generic-pool": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", - "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -13801,6 +14958,25 @@ "assert-plus": "^1.0.0" } }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -13842,6 +15018,32 @@ "process": "^0.11.10" } }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", @@ -14373,6 +15575,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -14493,6 +15696,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -15113,6 +16327,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -15247,6 +16471,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -16853,11 +18090,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/jspdf": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", @@ -16975,7 +18240,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/leaflet.markercluster": { "version": "1.5.3", @@ -17419,6 +18685,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -17454,6 +18733,16 @@ } } }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -17476,12 +18765,33 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -17489,6 +18799,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -17496,6 +18813,34 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -17747,6 +19092,19 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -18791,6 +20149,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", @@ -19287,6 +20646,7 @@ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -19379,6 +20739,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19423,6 +20784,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -19889,6 +21266,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -19965,6 +21343,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19977,6 +21356,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz", "integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -20027,6 +21407,7 @@ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", "license": "Hippocratic-2.1", + "peer": true, "dependencies": { "@react-leaflet/core": "^3.0.0" }, @@ -20167,6 +21548,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -20176,6 +21558,20 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -20277,23 +21673,6 @@ "node": ">=8" } }, - "node_modules/redis": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", - "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", - "license": "MIT", - "workspaces": [ - "./packages/*" - ], - "dependencies": { - "@redis/bloom": "1.2.0", - "@redis/client": "1.6.1", - "@redis/graph": "1.1.1", - "@redis/json": "1.0.7", - "@redis/search": "1.2.0", - "@redis/time-series": "1.1.0" - } - }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -20454,6 +21833,16 @@ "node": ">=0.10.0" } }, + "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/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -21164,6 +22553,45 @@ "dev": true, "license": "MIT" }, + "node_modules/size-limit": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-11.2.0.tgz", + "integrity": "sha512-2kpQq2DD/pRpx3Tal/qRW1SYwcIeQ0iq8li5CJHQgOC+FtPn2BVmuDtzUCgNnpCrbgtfEHqh+iWzxK+Tq6C+RQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bytes-iec": "^3.1.1", + "chokidar": "^4.0.3", + "jiti": "^2.4.2", + "lilconfig": "^3.1.3", + "nanospinner": "^1.2.2", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.11" + }, + "bin": { + "size-limit": "bin.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/size-limit/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -21194,6 +22622,7 @@ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", "license": "MIT", + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -21312,6 +22741,16 @@ "node": ">=6" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -21446,6 +22885,7 @@ "integrity": "sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -21982,6 +23422,19 @@ "node": "*" } }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -22234,6 +23687,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -22480,6 +23934,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22567,6 +24022,19 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -22893,6 +24361,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", @@ -22981,6 +24450,7 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -23106,6 +24576,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -23321,6 +24792,7 @@ "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-3.4.5.tgz", "integrity": "sha512-rmqnLRlyFWcP2VvvQtS1XMmupaSruxCwSTfwB8v7pRyeVDywsOoJwIpLh4PI5o//b3ia4P0gND4vkQ1MmU8C3g==", "license": "MIT", + "peer": true, "dependencies": { "eventemitter3": "5.0.1", "mipd": "0.0.7", @@ -23358,6 +24830,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -23791,6 +25264,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -23996,6 +25470,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx index 57a8b0da..cfddd733 100644 --- a/src/app/compare/page.tsx +++ b/src/app/compare/page.tsx @@ -5,8 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeft, Share2, Download, Clock, Trash2, FileText } from 'lucide-react'; import { propertyService } from '@/lib/propertyService'; -import { useComparisonHistoryStore } from '@/store/comparisonHistoryStore'; -import { useComparisonStore } from '@/store/comparisonStore'; +import { useCompareStore } from '@/store/compareStore'; import { withRouteErrorBoundary } from '@/components/error/withRouteErrorBoundary'; import type { Property } from '@/types/property'; import { formatPrice, formatROI } from '@/utils/searchUtils'; @@ -94,8 +93,15 @@ function getBestValue(properties: Property[], metric: ComparisonMetric): number function ComparePage() { const router = useRouter(); const searchParams = useSearchParams(); - const { selectedProperties, clearProperties } = useComparisonStore(); - const { addComparison, history, removeComparison, clearHistory } = useComparisonHistoryStore(); + // Selection and history come from the one compare store now (#1090). This + // page previously read selection from comparisonStore while ComparisonBar read + // compareStore, so the two could show different selections. + const selectedProperties = useCompareStore((state) => state.getSelectedProperties()); + const clearProperties = useCompareStore((state) => state.clearCompare); + const addComparison = useCompareStore((state) => state.addComparison); + const history = useCompareStore((state) => state.history); + const removeComparison = useCompareStore((state) => state.removeComparison); + const clearHistory = useCompareStore((state) => state.clearHistory); const [properties, setProperties] = useState([]); useEffect(() => { diff --git a/src/components/FloatingComparisonBar.tsx b/src/components/FloatingComparisonBar.tsx index a9ec85eb..11ba8db2 100644 --- a/src/components/FloatingComparisonBar.tsx +++ b/src/components/FloatingComparisonBar.tsx @@ -3,7 +3,7 @@ import React, { memo, useMemo } from 'react'; import Link from 'next/link'; import { X, BarChart3 } from 'lucide-react'; -import { useComparisonStore } from '@/store/comparisonStore'; +import { useCompareStore } from '@/store/compareStore'; import { formatPrice } from '@/utils/searchUtils'; // ============================================================================ @@ -41,7 +41,12 @@ const PropertyChip = memo(function PropertyChip({ name, price, onRemove }: Prope // ============================================================================ const FloatingComparisonBar = () => { - const { selectedProperties, removeProperty, clearProperties } = useComparisonStore(); + // Resolved from the selection cache (#1090). Ids whose object is not cached + // - a selection restored from storage or a share link - are omitted rather + // than rendered as placeholders. + const selectedProperties = useCompareStore((state) => state.getSelectedProperties()); + const removeProperty = useCompareStore((state) => state.removeProperty); + const clearProperties = useCompareStore((state) => state.clearCompare); const propertyCount = selectedProperties.length; diff --git a/src/components/PropertyCard.tsx b/src/components/PropertyCard.tsx index eea475dd..d534e73a 100644 --- a/src/components/PropertyCard.tsx +++ b/src/components/PropertyCard.tsx @@ -8,8 +8,7 @@ import type { Property } from '@/types/property'; import { formatPrice, formatNumber, formatROI, getBlockchainColor } from '@/utils/searchUtils'; import { BLOCKCHAIN_LABELS, PROPERTY_TYPE_LABELS } from '@/types/property'; import { useCartStore } from '@/store/cartStore'; -import { useComparisonStore } from '@/store/comparisonStore'; -import { useCompareStore } from '@/store/compareStore'; +import { MAX_COMPARE, useCompareStore } from '@/store/compareStore'; import { useFavoritesStore } from '@/store/favoritesStore'; import { ShareButton } from './property/ShareButton'; import { CurrencyToggle } from './property/CurrencyToggle'; @@ -31,14 +30,15 @@ const PropertyCardInner: React.FC = ({ }) => { const isListView = viewMode === 'list'; const { addItem } = useCartStore(); - const { isPropertySelected, toggleProperty } = useComparisonStore(); - - const isSelectedForComparison = isPropertySelected(property.id); const selectedIds = useCompareStore((state) => state.selectedIds); - const togglePropertyId = useCompareStore((state) => state.toggleProperty); + const toggleProperty = useCompareStore((state) => state.toggleProperty); + // One source of truth (#1090). This used to write to two stores per toggle + // while reading selection from one and the limit from the other, so the two + // could disagree. const isCompared = selectedIds.includes(property.id); - const compareLimitReached = selectedIds.length >= 3 && !isCompared; + const isSelectedForComparison = isCompared; + const compareLimitReached = selectedIds.length >= MAX_COMPARE && !isCompared; const { addFavorite, removeFavorite, isFavorite } = useFavoritesStore(); const handleAddToCart = (e: React.MouseEvent) => { @@ -48,13 +48,15 @@ const PropertyCardInner: React.FC = ({ const handleComparisonToggle = (e: React.MouseEvent) => { e.stopPropagation(); + // Passing the object caches it, so the floating bar can render a chip for a + // property the user just picked without refetching it. toggleProperty(property); }; const handleCompareToggle = (e: React.MouseEvent) => { e.stopPropagation(); if (!compareLimitReached) { - togglePropertyId(property.id); + toggleProperty(property); } }; diff --git a/src/components/__tests__/PropertyCard.a11y.test.tsx b/src/components/__tests__/PropertyCard.a11y.test.tsx index 64b02c0d..15888d0f 100644 --- a/src/components/__tests__/PropertyCard.a11y.test.tsx +++ b/src/components/__tests__/PropertyCard.a11y.test.tsx @@ -70,16 +70,20 @@ jest.mock('@/store/cartStore', () => ({ useCartStore: () => ({ addItem: jest.fn() }), })); -jest.mock('@/store/comparisonStore', () => ({ - useComparisonStore: () => ({ - isPropertySelected: jest.fn().mockReturnValue(false), - toggleProperty: jest.fn(), - }), -})); - jest.mock('@/store/compareStore', () => ({ - useCompareStore: (selector: (state: { selectedIds: string[]; toggleProperty: jest.Mock }) => unknown) => - selector({ selectedIds: [], toggleProperty: jest.fn() }), + useCompareStore: ( + selector: (state: { + selectedIds: string[]; + toggleProperty: jest.Mock; + isPropertySelected: () => boolean; + }) => unknown, + ) => + selector({ + selectedIds: [], + toggleProperty: jest.fn(), + isPropertySelected: () => false, + }), + MAX_COMPARE: 3, })); jest.mock('@/store/favoritesStore', () => ({ diff --git a/src/components/__tests__/PropertyCardContrast.test.tsx b/src/components/__tests__/PropertyCardContrast.test.tsx index 6ac82ff1..6f03c59e 100644 --- a/src/components/__tests__/PropertyCardContrast.test.tsx +++ b/src/components/__tests__/PropertyCardContrast.test.tsx @@ -46,14 +46,15 @@ const mockProperty: Property = { // Selector-aware mock so PropertyCard's zustand-style calls work in tests. jest.mock('@/store/cartStore', () => ({ useCartStore: () => ({ addItem: jest.fn() }) })); -jest.mock('@/store/comparisonStore', () => ({ - useComparisonStore: () => ({ isPropertySelected: () => false, toggleProperty: jest.fn() }), -})); jest.mock('@/store/compareStore', () => { - const stub = { selectedIds: [] as string[], toggleProperty: jest.fn() }; + const stub = { + selectedIds: [] as string[], + toggleProperty: jest.fn(), + isPropertySelected: () => false, + }; const useCompareStore = (selector?: (state: typeof stub) => unknown) => typeof selector === 'function' ? selector(stub) : stub; - return { useCompareStore }; + return { useCompareStore, MAX_COMPARE: 3 }; }); jest.mock('@/store/favoritesStore', () => ({ useFavoritesStore: () => ({ diff --git a/src/components/__tests__/PropertyListingSemantics.test.tsx b/src/components/__tests__/PropertyListingSemantics.test.tsx index d679201e..360b0a6a 100644 --- a/src/components/__tests__/PropertyListingSemantics.test.tsx +++ b/src/components/__tests__/PropertyListingSemantics.test.tsx @@ -62,14 +62,15 @@ const mockProperty: Property = { }; jest.mock('@/store/cartStore', () => ({ useCartStore: () => ({ addItem: jest.fn() }) })); -jest.mock('@/store/comparisonStore', () => ({ - useComparisonStore: () => ({ isPropertySelected: () => false, toggleProperty: jest.fn() }), -})); jest.mock('@/store/compareStore', () => { - const stub = { selectedIds: [] as string[], toggleProperty: jest.fn() }; + const stub = { + selectedIds: [] as string[], + toggleProperty: jest.fn(), + isPropertySelected: () => false, + }; const useCompareStore = (selector?: (state: typeof stub) => unknown) => typeof selector === 'function' ? selector(stub) : stub; - return { useCompareStore }; + return { useCompareStore, MAX_COMPARE: 3 }; }); jest.mock('@/store/favoritesStore', () => ({ useFavoritesStore: () => ({ diff --git a/src/hooks/usePropertyCardData.ts b/src/hooks/usePropertyCardData.ts index e9345b40..36e545b7 100644 --- a/src/hooks/usePropertyCardData.ts +++ b/src/hooks/usePropertyCardData.ts @@ -1,8 +1,7 @@ import { useMemo } from 'react'; import type { Property } from '@/types/property'; -import { useComparisonStore } from '@/store/comparisonStore'; -import { useCompareStore } from '@/store/compareStore'; +import { MAX_COMPARE, useCompareStore } from '@/store/compareStore'; import { useFavoritesStore } from '@/store/favoritesStore'; import { @@ -13,7 +12,7 @@ import { } from '@/utils/searchUtils'; export function usePropertyCardData(property: Property) { - const { isPropertySelected } = useComparisonStore(); + const isPropertySelected = useCompareStore((state) => state.isPropertySelected); const selectedIds = useCompareStore((state) => state.selectedIds); @@ -28,7 +27,7 @@ export function usePropertyCardData(property: Property) { isCompared, compareLimitReached: - selectedIds.length >= 3 && !isCompared, + selectedIds.length >= MAX_COMPARE && !isCompared, isFavorite: isFavorite(property.id), diff --git a/src/store/__tests__/compareStore.test.ts b/src/store/__tests__/compareStore.test.ts index bb698adc..0a620254 100644 --- a/src/store/__tests__/compareStore.test.ts +++ b/src/store/__tests__/compareStore.test.ts @@ -1,119 +1,211 @@ -import { act, renderHook } from '@testing-library/react'; -import { useCompareStore } from '../compareStore'; +/** + * Tests for the canonical compare store (#1090). + * + * Replaces the separate compareStore / comparisonStore / comparisonHistoryStore + * suites. Both slices are covered here because the point of the merge is that + * selection and history can no longer disagree with each other. + */ +import type { Property } from '@/types/property'; +import { MAX_COMPARE, MAX_HISTORY, useCompareStore } from '../compareStore'; + +const propertyFixture = (id: string): Property => + ({ + id, + name: `Property ${id}`, + price: { total: 100_000, currency: 'USD' }, + }) as unknown as Property; + +const reset = () => + useCompareStore.setState({ selectedIds: [], propertyCache: {}, history: [] }); + +describe('selection slice', () => { + beforeEach(reset); + + it('starts empty', () => { + const state = useCompareStore.getState(); + expect(state.selectedIds).toEqual([]); + expect(state.getSelectedProperties()).toEqual([]); + }); -describe('compareStore', () => { - beforeEach(() => { - localStorage.clear(); - useCompareStore.getState().clearCompare(); + it('adds by id', () => { + useCompareStore.getState().addProperty('a'); + expect(useCompareStore.getState().selectedIds).toEqual(['a']); }); - it('starts with no selected properties', () => { - const { result } = renderHook(() => useCompareStore()); - expect(result.current.selectedIds).toEqual([]); + it('adds by property and caches the object', () => { + useCompareStore.getState().addProperty(propertyFixture('a')); + + const state = useCompareStore.getState(); + expect(state.selectedIds).toEqual(['a']); + expect(state.getSelectedProperties()).toHaveLength(1); + expect(state.getSelectedProperties()[0].name).toBe('Property a'); }); - it('adds a property to the selection', () => { - const { result } = renderHook(() => useCompareStore()); + it('ignores a duplicate', () => { + const store = useCompareStore.getState(); + store.addProperty('a'); + store.addProperty('a'); + expect(useCompareStore.getState().selectedIds).toEqual(['a']); + }); - act(() => { - result.current.addProperty('prop-1'); - }); + it('caps the selection at MAX_COMPARE', () => { + const store = useCompareStore.getState(); + for (const id of ['a', 'b', 'c', 'd']) store.addProperty(id); - expect(result.current.selectedIds).toEqual(['prop-1']); + expect(useCompareStore.getState().selectedIds).toHaveLength(MAX_COMPARE); + expect(useCompareStore.getState().selectedIds).not.toContain('d'); }); - it('does not add a duplicate property', () => { - const { result } = renderHook(() => useCompareStore()); + it('removes by id and by property alike', () => { + const store = useCompareStore.getState(); + store.addProperty(propertyFixture('a')); + store.addProperty(propertyFixture('b')); - act(() => { - result.current.addProperty('prop-1'); - result.current.addProperty('prop-1'); - }); + useCompareStore.getState().removeProperty('a'); + expect(useCompareStore.getState().selectedIds).toEqual(['b']); - expect(result.current.selectedIds).toEqual(['prop-1']); + useCompareStore.getState().removeProperty(propertyFixture('b')); + expect(useCompareStore.getState().selectedIds).toEqual([]); }); - it('limits the selection to 3 properties', () => { - const { result } = renderHook(() => useCompareStore()); + it('drops the cached object when a selection is removed', () => { + useCompareStore.getState().addProperty(propertyFixture('a')); + useCompareStore.getState().removeProperty('a'); - act(() => { - result.current.addProperty('prop-1'); - result.current.addProperty('prop-2'); - result.current.addProperty('prop-3'); - result.current.addProperty('prop-4'); - }); + expect(useCompareStore.getState().propertyCache).toEqual({}); + }); + + it('toggles on and off', () => { + const toggle = () => useCompareStore.getState().toggleProperty('a'); - expect(result.current.selectedIds).toEqual(['prop-1', 'prop-2', 'prop-3']); + toggle(); + expect(useCompareStore.getState().isPropertySelected('a')).toBe(true); + + toggle(); + expect(useCompareStore.getState().isPropertySelected('a')).toBe(false); }); - it('removes a property from the selection', () => { - const { result } = renderHook(() => useCompareStore()); + it('does not toggle a fourth property in', () => { + const store = useCompareStore.getState(); + for (const id of ['a', 'b', 'c']) store.addProperty(id); + + useCompareStore.getState().toggleProperty('d'); - act(() => { - result.current.addProperty('prop-1'); - result.current.addProperty('prop-2'); - result.current.removeProperty('prop-1'); - }); + expect(useCompareStore.getState().isPropertySelected('d')).toBe(false); + expect(useCompareStore.getState().selectedIds).toHaveLength(MAX_COMPARE); + }); + + it('clears selection and cache together', () => { + useCompareStore.getState().addProperty(propertyFixture('a')); + useCompareStore.getState().clearCompare(); - expect(result.current.selectedIds).toEqual(['prop-2']); + const state = useCompareStore.getState(); + expect(state.selectedIds).toEqual([]); + expect(state.propertyCache).toEqual({}); }); - it('toggles a property in and out of the selection', () => { - const { result } = renderHook(() => useCompareStore()); + it('caps setSelectedIds and keeps only still-selected cache entries', () => { + useCompareStore.getState().addProperty(propertyFixture('a')); + useCompareStore.getState().addProperty(propertyFixture('b')); - act(() => { - result.current.toggleProperty('prop-1'); - }); - expect(result.current.selectedIds).toEqual(['prop-1']); + useCompareStore.getState().setSelectedIds(['b', 'x', 'y', 'z']); - act(() => { - result.current.toggleProperty('prop-1'); - }); - expect(result.current.selectedIds).toEqual([]); + const state = useCompareStore.getState(); + expect(state.selectedIds).toEqual(['b', 'x', 'y']); + // 'a' is no longer selected, so its object is not retained. + expect(Object.keys(state.propertyCache)).toEqual(['b']); }); - it('does not toggle beyond the 3-property limit', () => { - const { result } = renderHook(() => useCompareStore()); + it('returns selected properties in selection order, skipping uncached ids', () => { + const store = useCompareStore.getState(); + store.addProperty(propertyFixture('a')); + // Added by bare id, as a share link or restored session would. + store.addProperty('b'); + store.addProperty(propertyFixture('c')); - act(() => { - result.current.addProperty('prop-1'); - result.current.addProperty('prop-2'); - result.current.addProperty('prop-3'); - result.current.toggleProperty('prop-4'); - }); + const resolved = useCompareStore.getState().getSelectedProperties(); - expect(result.current.selectedIds).toEqual(['prop-1', 'prop-2', 'prop-3']); + expect(resolved.map((p) => p.id)).toEqual(['a', 'c']); }); +}); - it('setSelectedIds replaces the selection and clamps to 3', () => { - const { result } = renderHook(() => useCompareStore()); +describe('history slice', () => { + beforeEach(reset); - act(() => { - result.current.setSelectedIds(['a', 'b', 'c', 'd', 'e']); - }); + it('records a comparison with a share URL', () => { + useCompareStore.getState().addComparison(['a', 'b']); - expect(result.current.selectedIds).toEqual(['a', 'b', 'c']); + const [entry] = useCompareStore.getState().history; + expect(entry.propertyIds).toEqual(['a', 'b']); + expect(entry.shareUrl).toBe('/compare?ids=a,b'); + expect(entry.timestamp).toBeGreaterThan(0); + expect(entry.id).toMatch(/^comp/); }); - it('clearCompare empties the selection', () => { - const { result } = renderHook(() => useCompareStore()); + it('ignores an empty comparison', () => { + useCompareStore.getState().addComparison([]); + expect(useCompareStore.getState().history).toEqual([]); + }); + + it('puts the newest entry first', () => { + useCompareStore.getState().addComparison(['a']); + useCompareStore.getState().addComparison(['b']); + + expect(useCompareStore.getState().history[0].propertyIds).toEqual(['b']); + }); - act(() => { - result.current.addProperty('prop-1'); - result.current.clearCompare(); - }); + it('caps history at MAX_HISTORY', () => { + for (let i = 0; i < MAX_HISTORY + 3; i++) { + useCompareStore.getState().addComparison([`p${i}`]); + } - expect(result.current.selectedIds).toEqual([]); + expect(useCompareStore.getState().history).toHaveLength(MAX_HISTORY); }); - it('persists the selection across store instances', () => { - const { result } = renderHook(() => useCompareStore()); + it('removes a single entry by id', () => { + useCompareStore.getState().addComparison(['a']); + const { id } = useCompareStore.getState().history[0]; + + useCompareStore.getState().removeComparison(id); + + expect(useCompareStore.getState().history).toEqual([]); + }); + + it('clears all history', () => { + useCompareStore.getState().addComparison(['a']); + useCompareStore.getState().addComparison(['b']); + + useCompareStore.getState().clearHistory(); + + expect(useCompareStore.getState().getHistory()).toEqual([]); + }); + + it('keeps history independent of selection', () => { + // The reason history was a separate store; it must survive a clear. + useCompareStore.getState().addComparison(['a', 'b']); + useCompareStore.getState().addProperty('a'); + + useCompareStore.getState().clearCompare(); + + expect(useCompareStore.getState().selectedIds).toEqual([]); + expect(useCompareStore.getState().history).toHaveLength(1); + }); +}); + +describe('persistence', () => { + beforeEach(reset); + + it('persists selection, cache and history under one key', () => { + useCompareStore.getState().addProperty(propertyFixture('a')); + useCompareStore.getState().addComparison(['a']); - act(() => { - result.current.addProperty('prop-1'); - }); + const raw = window.localStorage.getItem('propchain-compare'); + expect(raw).not.toBeNull(); - const { result: result2 } = renderHook(() => useCompareStore()); - expect(result2.current.selectedIds).toEqual(['prop-1']); + const parsed = JSON.parse(raw as string); + expect(parsed.state.selectedIds).toEqual(['a']); + expect(parsed.state.history).toHaveLength(1); + expect(Object.keys(parsed.state.propertyCache)).toEqual(['a']); + expect(parsed.version).toBe(2); }); }); diff --git a/src/store/__tests__/comparisonHistoryStore.test.ts b/src/store/__tests__/comparisonHistoryStore.test.ts deleted file mode 100644 index b0de39da..00000000 --- a/src/store/__tests__/comparisonHistoryStore.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { useComparisonHistoryStore } from '../comparisonHistoryStore'; - -describe('comparisonHistoryStore', () => { - beforeEach(() => { - localStorage.clear(); - useComparisonHistoryStore.getState().clearHistory(); - }); - - it('starts with an empty history', () => { - const { result } = renderHook(() => useComparisonHistoryStore()); - expect(result.current.getHistory()).toEqual([]); - }); - - it('records a comparison with a share URL', () => { - const { result } = renderHook(() => useComparisonHistoryStore()); - - act(() => { - result.current.addComparison(['prop-1', 'prop-2']); - }); - - const history = result.current.getHistory(); - expect(history).toHaveLength(1); - expect(history[0].propertyIds).toEqual(['prop-1', 'prop-2']); - expect(history[0].shareUrl).toBe('/compare?ids=prop-1,prop-2'); - expect(history[0].timestamp).toBeGreaterThan(0); - }); - - it('ignores empty comparison requests', () => { - const { result } = renderHook(() => useComparisonHistoryStore()); - - act(() => { - result.current.addComparison([]); - }); - - expect(result.current.getHistory()).toEqual([]); - }); - - it('caps the history at 5 entries, evicting the oldest', () => { - const { result } = renderHook(() => useComparisonHistoryStore()); - - act(() => { - for (let i = 1; i <= 6; i++) { - result.current.addComparison([`prop-${i}`]); - } - }); - - const history = result.current.getHistory(); - expect(history).toHaveLength(5); - expect(history[0].propertyIds).toEqual(['prop-6']); - expect(history[4].propertyIds).toEqual(['prop-2']); - }); - - it('removes a single comparison entry', () => { - const { result } = renderHook(() => useComparisonHistoryStore()); - - act(() => { - result.current.addComparison(['prop-1']); - result.current.addComparison(['prop-2']); - }); - - const firstId = result.current.getHistory()[0].id; - act(() => { - result.current.removeComparison(firstId); - }); - - const history = result.current.getHistory(); - expect(history).toHaveLength(1); - expect(history[0].propertyIds).toEqual(['prop-1']); - }); - - it('clearHistory empties the history', () => { - const { result } = renderHook(() => useComparisonHistoryStore()); - - act(() => { - result.current.addComparison(['prop-1']); - result.current.clearHistory(); - }); - - expect(result.current.getHistory()).toEqual([]); - }); - - it('persists the history across store instances', () => { - const { result } = renderHook(() => useComparisonHistoryStore()); - - act(() => { - result.current.addComparison(['prop-1', 'prop-2']); - }); - - const { result: result2 } = renderHook(() => useComparisonHistoryStore()); - expect(result2.current.getHistory()).toHaveLength(1); - expect(result2.current.getHistory()[0].propertyIds).toEqual(['prop-1', 'prop-2']); - }); -}); diff --git a/src/store/__tests__/comparisonStore.test.ts b/src/store/__tests__/comparisonStore.test.ts deleted file mode 100644 index 7cfcb18a..00000000 --- a/src/store/__tests__/comparisonStore.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { useComparisonStore } from '../comparisonStore'; -import type { Property } from '@/types/property'; - -const mockProperty = (id: string): Property => ({ - id, - name: `Property ${id}`, - description: 'A test property', - location: { - address: '1 Test St', - city: 'Testville', - state: 'TS', - country: 'Testland', - zipCode: '00000', - coordinates: { lat: 0, lng: 0 }, - }, - price: { total: 100000, perToken: 100, currency: 'USD' }, - propertyType: 'residential', - blockchain: 'ethereum', - tokenInfo: { - totalSupply: 1000, - available: 900, - sold: 100, - contractAddress: '0x1234...5678', - tokenSymbol: 'TST', - }, - metrics: { - roi: 8, - annualReturn: 8000, - transactionVolume: 50000, - appreciationRate: 5, - }, - details: { squareFeet: 1000, yearBuilt: 2020, amenities: [] }, - images: [], - listedDate: '2024-01-01', - status: 'active', -}); - -describe('comparisonStore', () => { - beforeEach(() => { - localStorage.clear(); - useComparisonStore.getState().clearProperties(); - }); - - it('starts with no selected properties and a max of 3', () => { - const { result } = renderHook(() => useComparisonStore()); - expect(result.current.selectedProperties).toEqual([]); - expect(result.current.maxProperties).toBe(3); - }); - - it('adds a property to the selection', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.addProperty(mockProperty('prop-1')); - }); - - expect(result.current.selectedProperties).toHaveLength(1); - expect(result.current.selectedProperties[0].id).toBe('prop-1'); - }); - - it('does not add a property twice', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.addProperty(mockProperty('prop-1')); - result.current.addProperty(mockProperty('prop-1')); - }); - - expect(result.current.selectedProperties).toHaveLength(1); - }); - - it('stops adding once the max is reached', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.addProperty(mockProperty('prop-1')); - result.current.addProperty(mockProperty('prop-2')); - result.current.addProperty(mockProperty('prop-3')); - result.current.addProperty(mockProperty('prop-4')); - }); - - expect(result.current.selectedProperties).toHaveLength(3); - }); - - it('removes a property from the selection', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.addProperty(mockProperty('prop-1')); - result.current.addProperty(mockProperty('prop-2')); - result.current.removeProperty(mockProperty('prop-1')); - }); - - expect(result.current.selectedProperties).toHaveLength(1); - expect(result.current.selectedProperties[0].id).toBe('prop-2'); - }); - - it('reports whether a property is selected', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.addProperty(mockProperty('prop-1')); - }); - - expect(result.current.isPropertySelected('prop-1')).toBe(true); - expect(result.current.isPropertySelected('prop-2')).toBe(false); - }); - - it('toggles a property in and out of the selection', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.toggleProperty(mockProperty('prop-1')); - }); - expect(result.current.isPropertySelected('prop-1')).toBe(true); - - act(() => { - result.current.toggleProperty(mockProperty('prop-1')); - }); - expect(result.current.isPropertySelected('prop-1')).toBe(false); - }); - - it('does not toggle beyond the max selection size', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.addProperty(mockProperty('prop-1')); - result.current.addProperty(mockProperty('prop-2')); - result.current.addProperty(mockProperty('prop-3')); - result.current.toggleProperty(mockProperty('prop-4')); - }); - - expect(result.current.selectedProperties).toHaveLength(3); - }); - - it('clearProperties empties the selection', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.addProperty(mockProperty('prop-1')); - result.current.clearProperties(); - }); - - expect(result.current.selectedProperties).toEqual([]); - }); - - it('persists the selection across store instances', () => { - const { result } = renderHook(() => useComparisonStore()); - - act(() => { - result.current.addProperty(mockProperty('prop-1')); - }); - - const { result: result2 } = renderHook(() => useComparisonStore()); - expect(result2.current.selectedProperties).toHaveLength(1); - expect(result2.current.selectedProperties[0].id).toBe('prop-1'); - }); -}); diff --git a/src/store/compareStore.ts b/src/store/compareStore.ts index e23ecef1..7906de24 100644 --- a/src/store/compareStore.ts +++ b/src/store/compareStore.ts @@ -2,68 +2,221 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import type { Property } from '@/types/property'; +import { generateSecureId } from '@/utils/secureId'; + +/** Most properties that can be compared at once. */ +export const MAX_COMPARE = 3; + +/** Most comparison snapshots retained in history. */ +export const MAX_HISTORY = 5; + +/** A saved comparison, shareable by URL. */ +export interface ComparisonHistory { + id: string; + propertyIds: string[]; + timestamp: number; + shareUrl: string; +} interface CompareStore { + // ── selection slice ──────────────────────────────────────────────────── + /** + * The canonical selection. Ids rather than objects because an id is what the + * share URL (`/compare?ids=…`) and persistence carry, and what a card knows + * about itself. + */ selectedIds: string[]; - addProperty: (id: string) => void; - removeProperty: (id: string) => void; - toggleProperty: (id: string) => void; + /** + * Best-effort cache of the full objects, populated when a caller toggles with + * a `Property` rather than a bare id. + * + * Deliberately not authoritative: a selection restored from storage or from a + * share link has ids but no objects until something fetches them. UI that + * needs an object must tolerate a miss — `getSelectedProperties()` drops + * unresolved ids rather than inventing placeholders. + */ + propertyCache: Record; + + addProperty: (target: Property | string) => void; + removeProperty: (target: Property | string) => void; + toggleProperty: (target: Property | string) => void; clearCompare: () => void; setSelectedIds: (ids: string[]) => void; + isPropertySelected: (propertyId: string) => boolean; + /** Selected properties resolvable from the cache, ordered by selection. */ + getSelectedProperties: () => Property[]; + + // ── history slice ────────────────────────────────────────────────────── + history: ComparisonHistory[]; + addComparison: (propertyIds: string[]) => void; + removeComparison: (id: string) => void; + clearHistory: () => void; + getHistory: () => ComparisonHistory[]; } +/** Accepts either a `Property` or a bare id, since both call styles exist. */ +const idOf = (target: Property | string): string => + typeof target === 'string' ? target : target.id; + +/** + * Canonical compare store: selection and history in one module (#1090). + * + * Previously three stores split this. `compareStore` held ids, `comparisonStore` + * held whole `Property` objects under a separate persistence key, and + * `comparisonHistoryStore` held snapshots. A fourth module, + * `compareStoreCanonical`, declared `compareStore` authoritative but had no + * importers, so it changed nothing. + * + * The split was not merely untidy. `PropertyCard` wrote to *both* selection + * stores on every toggle while reading "is this selected" from one and "is the + * limit reached" from the other, so the two could disagree — and `/compare` read + * `comparisonStore` while `ComparisonBar` read `compareStore`, so the bar and + * the page could show different selections. One store removes the class of bug + * rather than patching an instance of it. + */ export const useCompareStore = create()( persist( (set, get) => ({ selectedIds: [], + propertyCache: {}, + history: [], + + addProperty: (target) => { + const id = idOf(target); - addProperty: (id: string) => { set((state) => { - if (state.selectedIds.includes(id) || state.selectedIds.length >= 3) { + if (state.selectedIds.includes(id) || state.selectedIds.length >= MAX_COMPARE) { return state; } return { selectedIds: [...state.selectedIds, id], + propertyCache: + typeof target === 'string' + ? state.propertyCache + : { ...state.propertyCache, [id]: target }, }; }); }, - removeProperty: (id: string) => { - set((state) => ({ - selectedIds: state.selectedIds.filter((selectedId) => selectedId !== id), - })); - }, + removeProperty: (target) => { + const id = idOf(target); - toggleProperty: (id: string) => { set((state) => { - if (state.selectedIds.includes(id)) { - return { - selectedIds: state.selectedIds.filter((selectedId) => selectedId !== id), - }; - } - - if (state.selectedIds.length >= 3) { - return state; - } + // The cache entry is dropped with the selection so a long session does + // not accumulate objects for properties nobody is comparing. + const { [id]: _removed, ...remainingCache } = state.propertyCache; return { - selectedIds: [...state.selectedIds, id], + selectedIds: state.selectedIds.filter((selectedId) => selectedId !== id), + propertyCache: remainingCache, }; }); }, - clearCompare: () => { - set({ selectedIds: [] }); + toggleProperty: (target) => { + const id = idOf(target); + + if (get().selectedIds.includes(id)) { + get().removeProperty(id); + return; + } + + get().addProperty(target); }, - setSelectedIds: (ids: string[]) => { - set({ selectedIds: ids.slice(0, 3) }); + clearCompare: () => set({ selectedIds: [], propertyCache: {} }), + + setSelectedIds: (ids) => + set((state) => { + const capped = ids.slice(0, MAX_COMPARE); + const cache: Record = {}; + + for (const id of capped) { + const cached = state.propertyCache[id]; + if (cached) cache[id] = cached; + } + + return { selectedIds: capped, propertyCache: cache }; + }), + + isPropertySelected: (propertyId) => get().selectedIds.includes(propertyId), + + getSelectedProperties: () => { + const { selectedIds, propertyCache } = get(); + + return selectedIds + .map((id) => propertyCache[id]) + .filter((property): property is Property => Boolean(property)); + }, + + addComparison: (propertyIds) => { + if (propertyIds.length === 0) return; + + const entry: ComparisonHistory = { + id: generateSecureId('comp'), + propertyIds, + timestamp: Date.now(), + shareUrl: `/compare?ids=${propertyIds.join(',')}`, + }; + + set((state) => ({ + history: [entry, ...state.history].slice(0, MAX_HISTORY), + })); }, + + removeComparison: (id) => + set((state) => ({ + history: state.history.filter((item) => item.id !== id), + })), + + clearHistory: () => set({ history: [] }), + + getHistory: () => get().history, }), { + // Same key as the previous selection store, so an existing selection + // survives this refactor rather than being silently dropped. name: 'propchain-compare', - partialize: (state) => ({ selectedIds: state.selectedIds }), - } - ) + version: 2, + partialize: (state) => ({ + selectedIds: state.selectedIds, + propertyCache: state.propertyCache, + history: state.history, + }), + /** + * v1 persisted only `selectedIds` under this key, and history lived under + * `propchain-comparison-history`. Carry both forward so users do not lose + * a selection or their recent comparisons on upgrade. + */ + migrate: (persisted, version) => { + const state = (persisted ?? {}) as Partial; + if (version >= 2) return state; + + let history: ComparisonHistory[] = state.history ?? []; + + if (history.length === 0 && typeof window !== 'undefined') { + try { + const legacy = window.localStorage.getItem('propchain-comparison-history'); + if (legacy) { + const parsed = JSON.parse(legacy) as { state?: { history?: ComparisonHistory[] } }; + history = parsed.state?.history ?? []; + } + } catch { + // A malformed or unreadable legacy entry is not worth failing the + // upgrade over; history is recoverable by comparing again. + history = []; + } + } + + return { + ...state, + selectedIds: state.selectedIds ?? [], + propertyCache: state.propertyCache ?? {}, + history, + }; + }, + }, + ), ); diff --git a/src/store/compareStoreCanonical.ts b/src/store/compareStoreCanonical.ts deleted file mode 100644 index 396b4c7b..00000000 --- a/src/store/compareStoreCanonical.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Canonical compare-selection accessor. - * - * PropertyCard, the compare page, and the header badge previously read - * selection state from two independent stores (`compareStore` and - * `comparisonStore`), which could disagree. `compareStore` is the single - * source of truth for the current selection; `comparisonHistoryStore` - * remains scoped to history snapshots only. Consumers should import - * `useCanonicalCompareSelection` instead of reading `comparisonStore` - * directly, so selection state can't fork again. - */ -import { useCompareStore } from './compareStore'; - -export function useCanonicalCompareSelection() { - const selectedIds = useCompareStore((state) => state.selectedIds ?? []); - const toggle = useCompareStore((state) => state.toggleProperty); - const clear = useCompareStore((state) => state.clearCompare); - - return { - selectedIds, - count: selectedIds.length, - toggle, - clear, - }; -} diff --git a/src/store/comparisonHistoryStore.ts b/src/store/comparisonHistoryStore.ts deleted file mode 100644 index 3ab7c91d..00000000 --- a/src/store/comparisonHistoryStore.ts +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; - -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; -import { genId } from '@/utils/genId'; -import { generateSecureId } from '@/utils/secureId'; - -export interface ComparisonHistory { - id: string; - propertyIds: string[]; - timestamp: number; - shareUrl: string; -} - -interface ComparisonHistoryStore { - history: ComparisonHistory[]; - addComparison: (propertyIds: string[]) => void; - removeComparison: (id: string) => void; - clearHistory: () => void; - getHistory: () => ComparisonHistory[]; -} - -const MAX_HISTORY = 5; - -export const useComparisonHistoryStore = create()( - persist( - (set, get) => ({ - history: [], - - addComparison: (propertyIds: string[]) => { - if (propertyIds.length === 0) return; - - const id = generateSecureId('comp'); - const shareUrl = `/compare?ids=${propertyIds.join(',')}`; - - const newComparison: ComparisonHistory = { - id, - propertyIds, - timestamp: Date.now(), - shareUrl, - }; - - set((state) => { - const updatedHistory = [newComparison, ...state.history].slice(0, MAX_HISTORY); - return { history: updatedHistory }; - }); - }, - - removeComparison: (id: string) => { - set((state) => ({ - history: state.history.filter((item) => item.id !== id), - })); - }, - - clearHistory: () => { - set({ history: [] }); - }, - - getHistory: () => { - return get().history; - }, - }), - { - name: 'propchain-comparison-history', - partialize: (state) => ({ history: state.history }), - } - ) -); diff --git a/src/store/comparisonStore.ts b/src/store/comparisonStore.ts deleted file mode 100644 index 721b179e..00000000 --- a/src/store/comparisonStore.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; -import type { Property } from '@/types/property'; - -interface ComparisonState { - selectedProperties: Property[]; - maxProperties: number; - addProperty: (property: Property) => void; - removeProperty: (property: Property) => void; - clearProperties: () => void; - isPropertySelected: (propertyId: string) => boolean; - toggleProperty: (property: Property) => void; -} - -export const useComparisonStore = create()( - persist( - (set, get) => ({ - selectedProperties: [], - maxProperties: 3, - - addProperty: (property: Property) => { - const { selectedProperties, maxProperties } = get(); - if (selectedProperties.length >= maxProperties) return; - - // Check if property is already selected - if (selectedProperties.some(p => p.id === property.id)) return; - - set({ selectedProperties: [...selectedProperties, property] }); - }, - - removeProperty: (property: Property) => { - const { selectedProperties } = get(); - set({ - selectedProperties: selectedProperties.filter(p => p.id !== property.id) - }); - }, - - clearProperties: () => { - set({ selectedProperties: [] }); - }, - - isPropertySelected: (propertyId: string) => { - const { selectedProperties } = get(); - return selectedProperties.some(p => p.id === propertyId); - }, - - toggleProperty: (property: Property) => { - const { isPropertySelected, addProperty, removeProperty } = get(); - if (isPropertySelected(property.id)) { - removeProperty(property); - } else { - addProperty(property); - } - }, - }), - { - name: 'property-comparison-storage', - partialize: (state) => ({ selectedProperties: state.selectedProperties }), - } - ) -); \ No newline at end of file diff --git a/src/store/domains/property/index.ts b/src/store/domains/property/index.ts index 497330eb..c996cf32 100644 --- a/src/store/domains/property/index.ts +++ b/src/store/domains/property/index.ts @@ -1,8 +1,8 @@ // Property domain - combines property, portfolio, favorites, comparison stores export { usePortfolioStore } from '../portfolioStore'; export { useFavoritesStore } from '../favoritesStore'; -export { useCompareStore } from '../compareStore'; -export { useComparisonStore } from '../comparisonStore'; -export { useComparisonHistoryStore } from '../comparisonHistoryStore'; +// Selection and history now live in one store (#1090). +export { useCompareStore, MAX_COMPARE, MAX_HISTORY } from '../compareStore'; +export type { ComparisonHistory } from '../compareStore'; export { useRecentlyViewedStore } from '../recentlyViewedStore'; export { useCertificateStore } from '../certificateStore'; From c9d52fbec81eb567dea87df377f50a34248b0c88 Mon Sep 17 00:00:00 2001 From: a-malik-gh Date: Sat, 26 Sep 2026 18:17:18 +0100 Subject: [PATCH 2/4] refactor(layout): extract the shared page header into one component The PropChain header was inlined in twenty places across src/app: the same chrome classes, the same max-w-7xl container, the same h-16 flex row, and the same PC-square-plus-wordmark brand. Only the two ends ever differed, so everything that varies is now a prop on src/components/layout/AppHeader.tsx and everything that does not lives there once. API: `sticky`, `backHref`/`backLabel`, a `leading` slot for the dashboard's mobile sidebar toggle, an `actions` slot for WalletConnector and friends, `brandHref` (null renders plain text, which is right on the home page), `containerClassName` because /alerts is laid out at max-w-4xl and its header has to line up with its own body, and `as` for the rare second header-shaped row that must not claim the banner landmark. Two accessibility bugs fixed on the way through: - The brand was `

PropChain

` on most pages, which also render an `

` for the page title. Every one of those pages had two level-1 headings and no unambiguous title. The brand is a `` now, so each page keeps exactly one `

` - its own. - Pages that rendered their own `
` while also mounting a component that rendered one produced two `banner` landmarks. One AppHeader per route keeps that to one. Also normalises what was inconsistent: some brands linked to `/`, some did not link at all, and back links varied between a bare anchor and a Button. Migrated: properties, transactions and dashboard (the three named in the issue), plus accessibility, governance, tax-report, secondary-market, watchlist, alerts, and both branches of dashboard/saved-searches. The properties/[id] headers are deliberately left alone - that tree is being consolidated under #1093 and two of the files holding those headers are removed there. PropertyPageSkeleton keeps its own placeholder markup, since it is a skeleton rather than the real header. 11 tests including a structural snapshot. Closes #1094 --- src/app/accessibility/page.tsx | 16 +- src/app/alerts/page.tsx | 43 +++-- src/app/dashboard/page.tsx | 40 ++--- src/app/dashboard/saved-searches/page.tsx | 33 +--- src/app/governance/page.tsx | 16 +- src/app/properties/page.tsx | 89 +++++----- src/app/secondary-market/page.tsx | 17 +- src/app/tax-report/page.tsx | 16 +- src/app/transactions/page.tsx | 32 +--- src/app/watchlist/page.tsx | 34 +--- src/components/layout/AppHeader.tsx | 154 ++++++++++++++++++ .../layout/__tests__/AppHeader.test.tsx | 107 ++++++++++++ .../__snapshots__/AppHeader.test.tsx.snap | 82 ++++++++++ 13 files changed, 444 insertions(+), 235 deletions(-) create mode 100644 src/components/layout/AppHeader.tsx create mode 100644 src/components/layout/__tests__/AppHeader.test.tsx create mode 100644 src/components/layout/__tests__/__snapshots__/AppHeader.test.tsx.snap diff --git a/src/app/accessibility/page.tsx b/src/app/accessibility/page.tsx index 9f60286f..c4bf0b3e 100644 --- a/src/app/accessibility/page.tsx +++ b/src/app/accessibility/page.tsx @@ -1,9 +1,9 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; -import Link from 'next/link'; import { useAxeAudit } from '@/hooks/useAxeAudit'; import { WalletConnector } from '@/components/WalletConnector'; +import { AppHeader } from '@/components/layout/AppHeader'; /** * Accessibility audit page — Issue #85 @@ -134,19 +134,7 @@ export default function AccessibilityPage() { {announcement} -
-
-
- - - PropChain - - -
-
-
+ } />

Accessibility

diff --git a/src/app/alerts/page.tsx b/src/app/alerts/page.tsx index d989d9cf..063b0657 100644 --- a/src/app/alerts/page.tsx +++ b/src/app/alerts/page.tsx @@ -28,6 +28,7 @@ import Link from 'next/link'; import { toast } from 'sonner'; import { WalletConnector } from '@/components/WalletConnector'; import { PriceAlertBell } from '@/components/PriceAlertBell'; +import { AppHeader } from '@/components/layout/AppHeader'; export default function AlertsPage() { const { @@ -77,30 +78,24 @@ export default function AlertsPage() { return (
{/* Header */} -
-
-
-
- - - -
-
- PC -
- PropChain -
-
-
- - -
-
-
-
+ + + + } + actions={ + <> + + + + } + />
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index c9ff05f7..794d4fd5 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -18,6 +18,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Sidebar } from "@/components/dashboard/Sidebar"; import { Menu } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { AppHeader } from "@/components/layout/AppHeader"; const StakingPanel = dynamic( () => import("@/components/dashboard/StakingPanel").then((m) => m.StakingPanel), @@ -95,31 +96,20 @@ const Index = () => { />
-
-
-
-
- {/* Mobile menu toggle */} - -
- PC -
-

- PropChain -

-
- -
-
-
+ setSidebarOpen(true)} + aria-label="Open sidebar" + > + + + } + actions={} + />
diff --git a/src/app/dashboard/saved-searches/page.tsx b/src/app/dashboard/saved-searches/page.tsx index f293f33b..88b760f4 100644 --- a/src/app/dashboard/saved-searches/page.tsx +++ b/src/app/dashboard/saved-searches/page.tsx @@ -28,6 +28,7 @@ import { Input } from "@/components/ui/input"; import { Search, Filter, Settings, Bell, Mail, Bookmark } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { CardSkeleton } from "@/components/ui/LoadingSkeletons"; +import { AppHeader } from "@/components/layout/AppHeader"; function SavedSearchesContent() { const { address } = useWalletStore(); @@ -88,21 +89,7 @@ function SavedSearchesContent() { if (!address) { return (
-
-
-
- -
- PC -
-

- PropChain -

- - -
-
-
+ } />
@@ -122,21 +109,7 @@ function SavedSearchesContent() { return (
{/* Header */} -
-
-
- -
- PC -
-

- PropChain -

- - -
-
-
+ } /> {/* Main Content */}
diff --git a/src/app/governance/page.tsx b/src/app/governance/page.tsx index c80fab09..9fc731a2 100644 --- a/src/app/governance/page.tsx +++ b/src/app/governance/page.tsx @@ -1,9 +1,9 @@ 'use client'; import React, { useState, useEffect, useCallback } from 'react'; -import Link from 'next/link'; import { WalletConnector } from '@/components/WalletConnector'; import { withRouteErrorBoundary } from '@/components/error/withRouteErrorBoundary'; +import { AppHeader } from '@/components/layout/AppHeader'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -265,19 +265,7 @@ function GovernancePage() { return (
{/* Header */} -
-
-
- - - PropChain - - -
-
-
+ } />
{/* Page title */} diff --git a/src/app/properties/page.tsx b/src/app/properties/page.tsx index 82a2b89b..d60d7890 100644 --- a/src/app/properties/page.tsx +++ b/src/app/properties/page.tsx @@ -17,6 +17,7 @@ import type { SortOption } from "@/types/property"; import Link from "next/link"; import { Heart } from "lucide-react"; import PropertyPageSkeleton from "@/components/PropertyPageSkeleton"; +import { AppHeader } from "@/components/layout/AppHeader"; function PropertiesContent() { const { viewMode: storeViewMode, setViewMode: setStoreViewMode } = @@ -98,56 +99,46 @@ function PropertiesContent() { return (
- {/* Header */} -
-
-
- -
- PC -
-

- PropChain -

- -
- - - - - - - - + +
-
-
-
+ Secondary Market + + + + + + + + + Watchlist + {favorites.length > 0 && ( + + {favorites.length} + + )} + + + + } + />
diff --git a/src/app/secondary-market/page.tsx b/src/app/secondary-market/page.tsx index f79e6cac..9208484e 100644 --- a/src/app/secondary-market/page.tsx +++ b/src/app/secondary-market/page.tsx @@ -11,6 +11,7 @@ import { withRouteErrorBoundary } from '@/components/error/withRouteErrorBoundar import Link from 'next/link'; import Image from 'next/image'; import { toast } from 'sonner'; +import { AppHeader } from '@/components/layout/AppHeader'; function SecondaryMarketPage() { const [listings, setListings] = useState([]); @@ -46,21 +47,7 @@ function SecondaryMarketPage() { return (
{/* Header */} -
-
-
- -
- PC -
-

PropChain

- -
- -
-
-
-
+ } />
diff --git a/src/app/tax-report/page.tsx b/src/app/tax-report/page.tsx index 297f88b3..4fc6ec53 100644 --- a/src/app/tax-report/page.tsx +++ b/src/app/tax-report/page.tsx @@ -1,9 +1,9 @@ 'use client'; import React, { useState } from 'react'; -import Link from 'next/link'; import { WalletConnector } from '@/components/WalletConnector'; import { withRouteErrorBoundary } from '@/components/error/withRouteErrorBoundary'; +import { AppHeader } from '@/components/layout/AppHeader'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -208,19 +208,7 @@ function TaxReportPage() { return (
{/* Header */} -
-
-
- - - PropChain - - -
-
-
+ } />
diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index 05f22257..de203084 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -1,13 +1,13 @@ 'use client'; import React from 'react'; -import Link from 'next/link'; import { useAccount } from 'wagmi'; import { useTranslation } from 'react-i18next'; import { TransactionHistory } from '@/components/TransactionHistory'; import { WalletConnector } from '@/components/WalletConnector'; -import { ArrowLeft, History } from 'lucide-react'; +import { History } from 'lucide-react'; import { EmptyState } from '@/components/ui/EmptyState'; +import { AppHeader } from '@/components/layout/AppHeader'; function TransactionsContent() { const { t } = useTranslation('common'); @@ -15,28 +15,12 @@ function TransactionsContent() { return (
-
-
-
-
- - - {t('transactions.backToDashboard')} - -
-
- PC -
-

PropChain

-
-
- -
-
-
+ } + />
diff --git a/src/app/watchlist/page.tsx b/src/app/watchlist/page.tsx index 15abb767..010c60bf 100644 --- a/src/app/watchlist/page.tsx +++ b/src/app/watchlist/page.tsx @@ -1,12 +1,12 @@ 'use client'; import React from 'react'; -import Link from 'next/link'; import { PropertyCard } from '@/components/PropertyCard'; import { useFavoritesStore } from '@/store/favoritesStore'; import { WalletConnector } from '@/components/WalletConnector'; -import { Heart, ArrowLeft } from 'lucide-react'; +import { Heart } from 'lucide-react'; import { EmptyState } from '@/components/ui/EmptyState'; +import { AppHeader } from '@/components/layout/AppHeader'; function WatchlistContent() { const { favorites, clearFavorites } = useFavoritesStore(); @@ -14,30 +14,12 @@ function WatchlistContent() { return (
{/* Header */} -
-
-
-
- - - Back to Properties - -
-
- PC -
-

- PropChain -

-
-
- -
-
-
+ } + /> {/* Main Content */}
diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx new file mode 100644 index 00000000..293ccabd --- /dev/null +++ b/src/components/layout/AppHeader.tsx @@ -0,0 +1,154 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** + * Issue #1094 — the single PropChain page header. + * + * The same header markup was inlined in twenty places across `src/app`: the + * chrome (`bg-white dark:bg-gray-800 shadow-sm border-b …`), the 7xl container, + * the `h-16` flex row, and the PC-square-plus-wordmark brand block. Only the + * contents of the two ends ever differed, so everything that varies is a prop + * and everything that does not lives here. + * + * ## Two behaviours this fixes on the way through + * + * **The brand is no longer an `

`.** Most pages rendered `

PropChain

` + * in the header *and* an `

` for the page title, giving every page two level-1 + * headings and making the real title ambiguous to a screen reader. The brand is + * a `` here, so each page keeps exactly one `

` — its own. + * + * **One banner landmark per page.** A page that rendered its own `
` and + * also mounted a component that rendered one produced two `banner` landmarks. + * Rendering this component once per route keeps that to one; `as="div"` is + * available for the rare case where a second header-shaped block is wanted + * inside a page without claiming the landmark. + */ +export interface AppHeaderProps { + /** + * Pin the header to the top of the viewport. Most routes do; the dashboard + * shell does not, because it scrolls inside its own `
`. + */ + sticky?: boolean; + /** Back link rendered before the brand. */ + backHref?: string; + /** + * Visible label for the back link, hidden below `sm`. When omitted the link + * still carries an accessible name via `aria-label`. + */ + backLabel?: string; + /** Slot before the brand — e.g. the dashboard's mobile sidebar toggle. */ + leading?: ReactNode; + /** Right-hand slot. `WalletConnector` is the common case. */ + actions?: ReactNode; + /** + * Where the brand links to. Pass `null` to render it as plain text, which is + * the right choice on the home page where the link would point at itself. + */ + brandHref?: string | null; + /** + * Render as a plain `div` instead of `
`, so a second header-shaped + * row inside a page does not create a competing `banner` landmark. + */ + as?: "header" | "div"; + className?: string; + /** + * Override the inner container width. Defaults to `max-w-7xl`, matching most + * routes; `/alerts` is laid out at `max-w-4xl` and its header must line up + * with its own body rather than with the wider pages. + */ + containerClassName?: string; +} + +/** + * The PC square plus the PropChain wordmark. + * + * Deliberately not a heading: see the note on {@link AppHeaderProps}. + */ +function Brand() { + return ( + + + + PropChain + + + ); +} + +/** + * Renders the one PropChain page header. + * + * @param props - See {@link AppHeaderProps}. + * @returns The header row, with `leading`/back-link/brand on the left and + * `actions` on the right. + */ +export function AppHeader({ + sticky = false, + backHref, + backLabel, + leading, + actions, + brandHref = "/", + as: Element = "header", + className, + containerClassName, +}: AppHeaderProps) { + return ( + +
+
+
+ {leading} + + {backHref ? ( + +
+ + {actions ? ( +
{actions}
+ ) : null} +
+
+
+ ); +} + +export default AppHeader; diff --git a/src/components/layout/__tests__/AppHeader.test.tsx b/src/components/layout/__tests__/AppHeader.test.tsx new file mode 100644 index 00000000..df440576 --- /dev/null +++ b/src/components/layout/__tests__/AppHeader.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from '@testing-library/react'; +import { AppHeader } from '@/components/layout/AppHeader'; + +/** + * Issue #1094 — the shared header. + * + * The interesting assertions are the accessibility ones. The twenty inlined + * copies this replaces mostly rendered `

PropChain

`, so a page ended up + * with two level-1 headings, and any page that also mounted a header-rendering + * component ended up with two `banner` landmarks. Both are pinned here so they + * cannot come back. + */ +describe('AppHeader', () => { + it('renders exactly one banner landmark', () => { + render(); + expect(screen.getAllByRole('banner')).toHaveLength(1); + }); + + it('does not render the brand as a heading', () => { + render(); + + expect(screen.getByText('PropChain')).toBeInTheDocument(); + // The page owns the only

; the brand must not compete with it. + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); + + it('links the brand to the home page by default', () => { + render(); + const link = screen.getByRole('link'); + + expect(link).toHaveAttribute('href', '/'); + expect(link).toHaveTextContent('PropChain'); + }); + + it('renders the brand as plain text when brandHref is null', () => { + render(); + + expect(screen.getByText('PropChain')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders a labelled back link when backHref is given', () => { + render(); + + const back = screen.getByRole('link', { name: 'Back to dashboard' }); + expect(back).toHaveAttribute('href', '/dashboard'); + }); + + it('still names the back link when no visible label is supplied', () => { + render(); + + expect(screen.getByRole('link', { name: 'Go back' })).toHaveAttribute( + 'href', + '/properties', + ); + }); + + it('renders leading and action slots', () => { + render( + Open sidebar} + actions={} + />, + ); + + expect( + screen.getByRole('button', { name: 'Open sidebar' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Connect wallet' }), + ).toBeInTheDocument(); + }); + + it('applies sticky positioning only when asked', () => { + const { container: plain } = render(); + expect(plain.querySelector('header')).not.toHaveClass('sticky'); + + const { container: stuck } = render(); + expect(stuck.querySelector('header')).toHaveClass('sticky', 'top-0'); + }); + + it('can render without claiming the banner landmark', () => { + render(); + + expect(screen.queryByRole('banner')).not.toBeInTheDocument(); + expect(screen.getByText('PropChain')).toBeInTheDocument(); + }); + + it('allows the container width to be overridden', () => { + const { container } = render(); + + expect(container.querySelector('.max-w-4xl')).toBeInTheDocument(); + }); + + it('matches the rendered structure', () => { + const { container } = render( + wallet} + />, + ); + + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/layout/__tests__/__snapshots__/AppHeader.test.tsx.snap b/src/components/layout/__tests__/__snapshots__/AppHeader.test.tsx.snap new file mode 100644 index 00000000..e5a3c6d9 --- /dev/null +++ b/src/components/layout/__tests__/__snapshots__/AppHeader.test.tsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AppHeader matches the rendered structure 1`] = ` +
+ +
+`; From e91ba8ea41112ea651aebf4f2d3d0b956776f577 Mon Sep 17 00:00:00 2001 From: a-malik-gh Date: Sat, 26 Sep 2026 18:21:34 +0100 Subject: [PATCH 3/4] refactor(property): consolidate the property detail component trees Property detail rendered through four modules and two page files. Only one path was ever live. What was there: app/properties/[id]/page.tsx (the route) -> components/PropertyDetailServer server render, live -> components/PropertyDetailClient "client", took propertyId app/properties/[id]/page-new.tsx (not a route; Next only routes page.tsx) -> app/properties/[id]/PropertyDetailClient took `property` app/properties/[id]/PropertyDetailPageClient.tsx (no importers at all) -> components/PropertyDetail legacy So two different components were called PropertyDetailClient with different prop shapes, and the second tree was reachable only from page-new.tsx, which carried a hardcoded mock property object. After: one server entry and one client island. app/properties/[id]/page.tsx -> components/PropertyDetailServer -> components/property/PropertyDetailHeaderActions components/PropertyDetailClient was renamed to components/property/PropertyDetailHeaderActions, because that is what it was: two buttons in the header, not a client rendering of the detail page. The misleading name is most of why the duplicate appeared - nothing about it said "header actions", so the obvious name was still free for someone building the real thing. Its unused `propertyId` prop is gone; the bell and the connector both resolve their own state. Removed: components/PropertyDetail.tsx, components/PropertyDetailClient.tsx, app/properties/[id]/PropertyDetailClient.tsx, app/properties/[id]/PropertyDetailPageClient.tsx, and app/properties/[id]/page-new.tsx. Nothing outside the dead tree referenced any of them. The route header now uses AppHeader (#1094), which removes the duplicate

and the second banner landmark this page had. Two things were only implemented in the deleted files and are worth a maintainer decision rather than a silent port, since neither ever shipped: page-new.tsx had a generateMetadata plus property and breadcrumb structured data that the live route does not have, and [id]/PropertyDetailClient.tsx had a QR-code share and a SetPriceAlertModal wired to the notification store. Both are recoverable from this commit. The existing PropertyDetailServer suite is untouched and still passes. Adds structural tests that fail if a deleted module is imported again or a second PropertyDetailClient reappears. Closes #1093 --- .../properties/[id]/PropertyDetailClient.tsx | 316 ------------- .../[id]/PropertyDetailPageClient.tsx | 61 --- src/app/properties/[id]/page-new.tsx | 176 -------- src/app/properties/[id]/page.tsx | 36 +- src/components/PropertyDetail.tsx | 424 ------------------ src/components/PropertyDetailClient.tsx | 18 - .../property/PropertyDetailHeaderActions.tsx | 31 ++ .../PropertyDetailConsolidation.test.tsx | 101 +++++ 8 files changed, 140 insertions(+), 1023 deletions(-) delete mode 100644 src/app/properties/[id]/PropertyDetailClient.tsx delete mode 100644 src/app/properties/[id]/PropertyDetailPageClient.tsx delete mode 100644 src/app/properties/[id]/page-new.tsx delete mode 100644 src/components/PropertyDetail.tsx delete mode 100644 src/components/PropertyDetailClient.tsx create mode 100644 src/components/property/PropertyDetailHeaderActions.tsx create mode 100644 src/components/property/__tests__/PropertyDetailConsolidation.test.tsx diff --git a/src/app/properties/[id]/PropertyDetailClient.tsx b/src/app/properties/[id]/PropertyDetailClient.tsx deleted file mode 100644 index 063258ee..00000000 --- a/src/app/properties/[id]/PropertyDetailClient.tsx +++ /dev/null @@ -1,316 +0,0 @@ -'use client'; - -import React, { useEffect, useRef, useState } from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -import QRCode from 'qrcode'; -import type { Property } from '@/types/property'; -import { WalletConnector } from '@/components/WalletConnector'; -import { PriceAlertBell } from '@/components/PriceAlertBell'; -import { SetPriceAlertModal } from '@/components/property/SetPriceAlertModal'; -import { useNotificationStore } from '@/store/notificationStore'; -import { toast } from 'sonner'; -import { useI18nFormatting } from '@/utils/i18nFormatting'; -import type { PriceAlertType } from '@/types/property'; - -interface Props { - property: Property; -} - -export function PropertyDetailClient({ property }: Props) { - const qrRef = useRef(null); - const [qrGenerated, setQrGenerated] = useState(false); - const [copied, setCopied] = useState(false); - const [isAlertModalOpen, setIsAlertModalOpen] = useState(false); - const { priceAlerts, addPriceAlert } = useNotificationStore(); - const existingAlert = priceAlerts.find((a) => a.propertyId === property.id); - const { formatCurrency, formatNumber, formatDate } = useI18nFormatting(); - - const handleSetAlert = (alertType: PriceAlertType, targetPrice: number, emailNotification: boolean) => { - addPriceAlert({ - id: `alert-${property.id}-${Date.now()}`, - propertyId: property.id, - propertyName: property.name, - propertyImage: property.images[0], - alertType, - targetPrice, - currentPrice: property.price.perToken, - createdAt: new Date().toISOString(), - isActive: true, - isTriggered: false, - userId: '', - emailNotification, - }); - toast.success('Price alert set successfully'); - }; - - const shareUrl = - typeof window !== 'undefined' - ? window.location.href - : `https://propchain.io/properties/${property.id}`; - - useEffect(() => { - if (qrRef.current) { - QRCode.toCanvas(qrRef.current, shareUrl, { width: 160, margin: 1 }, () => { - setQrGenerated(true); - }); - } - }, [shareUrl]); - - const handleCopy = async () => { - await navigator.clipboard.writeText(shareUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- {/* Header */} -
-
-
- - - PropChain - -
- - -
-
-
-
- -
- {/* Breadcrumb */} - - -
- {/* Left: Images + Details */} -
- {/* Hero Image */} - {property.images[0] && ( -
- {`${property.name} -
- )} - - {/* Property Info */} -
-
-
-

{property.name}

-

- {property.location.address}, {property.location.city}, {property.location.state} -

-
-
- {property.verified && ( - - Verified - - )} - {property.featured && ( - - Featured - - )} -
-
-

{property.description}

-
- - {/* Metrics */} -
-

Investment Metrics

-
- {[ - { label: 'Annual ROI', value: `${property.metrics.roi}%` }, - { label: 'Annual Return', value: formatCurrency(property.metrics.annualReturn) }, - { label: 'Appreciation', value: `${property.metrics.appreciationRate}%` }, - { label: 'Volume', value: formatCurrency(property.metrics.transactionVolume) }, - ].map(({ label, value }) => ( -
-
{label}
-
{value}
-
- ))} -
-
- - {/* Property Details */} -
-

Property Details

-
- {property.details.bedrooms !== undefined && ( -
Bedrooms
{property.details.bedrooms}
- )} - {property.details.bathrooms !== undefined && ( -
Bathrooms
{property.details.bathrooms}
- )} -
Square Feet
{formatNumber(property.details.squareFeet)} sqft
-
Year Built
{property.details.yearBuilt}
-
Listed
{formatDate(property.listedDate)}
-
Type
{property.propertyType}
-
Blockchain
{property.blockchain}
-
- {property.details.amenities.length > 0 && ( -
-
Amenities
-
- {property.details.amenities.map((a) => ( - {a} - ))} -
-
- )} -
-
- - {/* Right: Purchase + Share */} -
- {/* Pricing Card */} -
-
-

{formatCurrency(property.price.total)}

-

{formatCurrency(property.price.perToken)} per token

-
- - {/* Token availability */} -
-
- Tokens Available - {formatNumber(property.tokenInfo.available)} / {formatNumber(property.tokenInfo.totalSupply)} -
-
-
-
-
- - - - - -

- Token: {property.tokenInfo.tokenSymbol} · {property.blockchain} -

-
- - {/* Share / QR Code */} -
-

Share This Property

- - {/* QR Code */} -
- - {!qrGenerated && ( - - - {/* Copy link */} -
- - -
- - {/* Social share links */} - -
-
-
-
- - -
- ); -} diff --git a/src/app/properties/[id]/PropertyDetailPageClient.tsx b/src/app/properties/[id]/PropertyDetailPageClient.tsx deleted file mode 100644 index c1aea4c2..00000000 --- a/src/app/properties/[id]/PropertyDetailPageClient.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import React from 'react'; -import { PropertyDetail } from '@/components/PropertyDetail'; -import { WalletConnector } from '@/components/WalletConnector'; -import { Button } from '@/components/ui/button'; -import Link from 'next/link'; -import { ArrowLeft, Monitor } from 'lucide-react'; - -interface PropertyDetailPageClientProps { - propertyId: string; -} - -export function PropertyDetailPageClient({ propertyId }: PropertyDetailPageClientProps) { - return ( -
- {/* Header */} -
-
-
-
- - - -
-
- PC -
-

- PropChain -

-
-
-
- - - - - - - -
-
-
-
- - {/* Property Detail */} -
- -
-
- ); -} diff --git a/src/app/properties/[id]/page-new.tsx b/src/app/properties/[id]/page-new.tsx deleted file mode 100644 index db59edb3..00000000 --- a/src/app/properties/[id]/page-new.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import type { Metadata } from 'next'; -import { generatePropertyMetadata, generatePropertyStructuredData, generateBreadcrumbStructuredData } from '@/utils/seo'; -import PropertyDetailClient from './PropertyDetailClient'; - -// Mock property data - in a real app this would come from an API -const mockProperties = { - '1': { - id: '1', - name: 'Manhattan Tower Suite', - location: { - address: '123 Wall Street', - city: 'New York', - state: 'NY', - country: 'USA', - zipCode: '10005', - coordinates: { lat: 40.7074, lng: -74.0113 } - }, - type: 'Commercial', - description: 'Luxury commercial space in the heart of Manhattan with premium amenities and excellent connectivity.', - images: [ - 'https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&auto=format&fit=crop&q=80', - 'https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&auto=format&fit=crop&q=80' - ], - value: 524000, - tokens: 1048, - roi: 14.2, - monthlyIncome: 3280, - contractAddress: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', - blockchain: 'ethereum', - details: { - bedrooms: 0, - bathrooms: 2, - squareFeet: 2500, - yearBuilt: 2019, - parking: 4 - }, - tokenInfo: { - available: 524, - totalSupply: 1048, - perToken: 500 - }, - listedDate: '2023-01-15T00:00:00Z', - updatedAt: '2023-12-01T00:00:00Z', - price: { - total: 524000, - perToken: 500, - currency: 'USD' - }, - propertyType: 'commercial', - metrics: { - roi: 14.2, - annualReturn: 62400, - transactionVolume: 1500000, - appreciationRate: 8.5 - }, - featured: true, - verified: true - }, - '2': { - id: '2', - name: 'Sunset Beach Villa', - location: { - address: '456 Ocean Drive', - city: 'Miami', - state: 'FL', - country: 'USA', - zipCode: '33139', - coordinates: { lat: 25.7617, lng: -80.1918 } - }, - type: 'Residential', - description: 'Beautiful beachfront property with stunning ocean views and modern amenities.', - images: [ - 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&auto=format&fit=crop&q=80', - 'https://images.unsplash.com/photo-1545324418-cc1a3fa10c00?w=800&auto=format&fit=crop&q=80' - ], - value: 389000, - tokens: 778, - roi: 11.8, - monthlyIncome: 2450, - contractAddress: '0x1234567890123456789012345678901234567890', - blockchain: 'polygon', - details: { - bedrooms: 4, - bathrooms: 3, - squareFeet: 3200, - yearBuilt: 2021, - parking: 2 - }, - tokenInfo: { - available: 389, - totalSupply: 778, - perToken: 500 - }, - listedDate: '2023-03-20T00:00:00Z', - updatedAt: '2023-11-15T00:00:00Z', - price: { - total: 389000, - perToken: 500, - currency: 'USD' - }, - propertyType: 'residential', - metrics: { - roi: 11.8, - annualReturn: 45960, - transactionVolume: 800000, - appreciationRate: 6.2 - }, - featured: false, - verified: true - } -}; - -interface PropertyPageProps { - params: { id: string }; - searchParams: { [key: string]: string | string[] | undefined }; -} - -// Generate dynamic metadata for the property -export async function generateMetadata({ params }: PropertyPageProps): Promise { - const property = mockProperties[params.id as keyof typeof mockProperties]; - - if (!property) { - return { - title: 'Property Not Found | PropChain', - description: 'The requested property could not be found.', - }; - } - - return generatePropertyMetadata(property, `/properties/${params.id}`); -} - -// Generate JSON-LD structured data -function generateStructuredData(property: any, url: string) { - const propertyStructuredData = generatePropertyStructuredData(property, url); - const breadcrumbStructuredData = generateBreadcrumbStructuredData([ - { name: 'Home', url: '/' }, - { name: 'Properties', url: '/properties' }, - { name: property.name, url: `/properties/${property.id}` } - ]); - - return { - __html: `${propertyStructuredData}\n${breadcrumbStructuredData}` - }; -} - -export default function PropertyPage({ params }: PropertyPageProps) { - const property = mockProperties[params.id as keyof typeof mockProperties]; - const url = `/properties/${params.id}`; - - if (!property) { - return ( -
-
-

Property Not Found

-

The property you're looking for doesn't exist or has been removed.

- - Back to Properties - -
-
- ); - } - - return ( - <> -