diff --git a/package-lock.json b/package-lock.json index 79cfd043..8deafb1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,6 +151,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", @@ -2105,6 +2106,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -2145,6 +2147,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -3173,7 +3176,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3347,6 +3349,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3368,6 +3371,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -3380,6 +3384,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3803,6 +3808,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3819,6 +3825,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/resources": "2.5.1", @@ -3836,6 +3843,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -4743,6 +4751,7 @@ "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.7.1.tgz", "integrity": "sha512-eQIaSOw7frPkxaHQfxeWMUlU4VXEn7hrhKsefyfTpYn8ROcjjoXWUCa0zr3kvDEtSGw8ED7JIiTFnFJtZ5If1w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -4785,6 +4794,7 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.8.0.tgz", "integrity": "sha512-NNYuyW8qmLjyHnpyFgs/23wUrjB8k0xN9YIZFOMLewCa/pIkIji9e9aY/EgdNryEDDRptc6TcPIHRvG1R0ClFw==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.16" } @@ -4958,6 +4968,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5445,8 +5456,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5525,7 +5535,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5536,7 +5545,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -5552,8 +5560,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/mysql": { "version": "2.15.27", @@ -5599,6 +5606,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5609,6 +5617,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5769,7 +5778,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -5779,29 +5787,25 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -5812,15 +5816,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -5833,7 +5835,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -5843,7 +5844,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -5852,15 +5852,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -5877,7 +5875,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -5891,7 +5888,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -5904,7 +5900,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -5919,7 +5914,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -5929,21 +5923,20 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5965,7 +5958,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -6007,7 +5999,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -6025,7 +6016,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -6039,7 +6029,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -6050,7 +6039,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6196,6 +6184,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6214,8 +6203,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", @@ -6275,7 +6263,6 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -6548,8 +6535,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -6738,7 +6724,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -6752,7 +6737,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -6765,7 +6749,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -6775,7 +6758,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -6801,7 +6783,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -6820,8 +6801,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", @@ -6837,8 +6817,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fdir": { "version": "6.5.0", @@ -6933,8 +6912,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/graceful-fs": { "version": "4.2.11", @@ -6947,7 +6925,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -7104,7 +7081,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -7230,8 +7206,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -7681,7 +7656,6 @@ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" }, @@ -7749,7 +7723,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7774,15 +7747,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -7792,7 +7763,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -7868,14 +7838,14 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -8313,7 +8283,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8370,6 +8339,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8379,6 +8349,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8391,8 +8362,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -8544,6 +8514,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -8607,7 +8578,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -8712,7 +8682,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8731,7 +8700,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -8804,7 +8772,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -8933,7 +8900,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -8952,7 +8918,6 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -8985,8 +8950,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/third-party-capital": { "version": "1.0.20", @@ -9105,6 +9069,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9222,6 +9187,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9387,7 +9353,6 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -9411,7 +9376,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -9460,7 +9424,6 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -9469,8 +9432,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/whatwg-mimetype": { "version": "5.0.0", diff --git a/src/app/[country]/[locale]/(storefront)/t/[...permalink]/CategoryProductsContent.tsx b/src/app/[country]/[locale]/(storefront)/t/[...permalink]/CategoryProductsContent.tsx index cb4d4566..9cb89581 100644 --- a/src/app/[country]/[locale]/(storefront)/t/[...permalink]/CategoryProductsContent.tsx +++ b/src/app/[country]/[locale]/(storefront)/t/[...permalink]/CategoryProductsContent.tsx @@ -60,7 +60,6 @@ export function CategoryProductsContent({ = { - manual: "Manual", - best_selling: "Best Selling", - "price asc": "Price (low-high)", - "price desc": "Price (high-low)", - "available_on desc": "Newest", - "available_on asc": "Oldest", - "name asc": "Name (A-Z)", - "name desc": "Name (Z-A)", -}; - -const AVAILABILITY_LABELS: Record = { - in_stock: "In Stock", - out_of_stock: "Out of Stock", -}; - -interface ProductFiltersProps { - taxonId?: string; - filtersData: ProductFiltersResponse | null; - loading: boolean; - onFilterChange: (filters: ActiveFilters) => void; -} - -export interface ActiveFilters { - priceMin?: number; - priceMax?: number; - optionValues: string[]; // option value IDs - availability?: "in_stock" | "out_of_stock"; - sortBy?: string; -} - -export const ProductFilters = memo(function ProductFilters({ - filtersData, - loading, - onFilterChange, -}: ProductFiltersProps) { - const [activeFilters, setActiveFilters] = useState({ - optionValues: [], - }); - const [expandedSections, setExpandedSections] = useState>( - new Set(["price"]), - ); - - const updateFilters = (updater: (prev: ActiveFilters) => ActiveFilters) => { - const next = updater(activeFilters); - setActiveFilters(next); - onFilterChange(next); - }; - - const toggleSection = (sectionId: string) => { - setExpandedSections((prev) => { - const next = new Set(prev); - if (next.has(sectionId)) { - next.delete(sectionId); - } else { - next.add(sectionId); - } - return next; - }); - }; - - const handleOptionValueToggle = (optionValueId: string) => { - updateFilters((prev) => { - const newOptionValues = prev.optionValues.includes(optionValueId) - ? prev.optionValues.filter((id) => id !== optionValueId) - : [...prev.optionValues, optionValueId]; - return { ...prev, optionValues: newOptionValues }; - }); - }; - - const handlePriceChange = (min?: number, max?: number) => { - updateFilters((prev) => ({ ...prev, priceMin: min, priceMax: max })); - }; - - const handleAvailabilityChange = ( - availability?: "in_stock" | "out_of_stock", - ) => { - updateFilters((prev) => ({ ...prev, availability })); - }; - - const handleSortChange = (sortBy: string) => { - updateFilters((prev) => ({ ...prev, sortBy })); - }; - - const clearFilters = () => { - const reset: ActiveFilters = { optionValues: [] }; - setActiveFilters(reset); - onFilterChange(reset); - }; - - if (loading) { - return ( -
-
-
-
-
-
- ); - } - - if (!filtersData) { - return null; - } - - const hasActiveFilters = - activeFilters.priceMin !== undefined || - activeFilters.priceMax !== undefined || - activeFilters.optionValues.length > 0 || - activeFilters.availability !== undefined; - - return ( -
- {/* Sort */} -
- - -
- - {/* Reset Filters */} - {hasActiveFilters && ( - - )} - - {/* Filters */} - {filtersData.filters.map((filter) => { - switch (filter.type) { - case "price_range": - return ( - toggleSection(filter.id)} - > - - - ); - case "availability": - return ( - toggleSection(filter.id)} - > - - - ); - case "option": - return ( - toggleSection(filter.id)} - > - - - ); - default: - return null; - } - })} - - {/* Total count */} -
- {filtersData.total_count} products -
-
- ); -}); - -// Filter Section wrapper with expand/collapse -function FilterSection({ - title, - expanded, - onToggle, - children, -}: { - title: string; - expanded: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - return ( -
- - {expanded &&
{children}
} -
- ); -} - -// Price Range Filter -function PriceFilter({ - filter, - minValue, - maxValue, - onChange, -}: { - filter: PriceRangeFilter; - minValue?: number; - maxValue?: number; - onChange: (min?: number, max?: number) => void; -}) { - const [localMin, setLocalMin] = useState(minValue?.toString() || ""); - const [localMax, setLocalMax] = useState(maxValue?.toString() || ""); - - const handleApply = () => { - onChange( - localMin ? parseFloat(localMin) : undefined, - localMax ? parseFloat(localMax) : undefined, - ); - }; - - return ( -
-
- Range: {filter.currency} {filter.min.toFixed(2)} -{" "} - {filter.max.toFixed(2)} -
-
- setLocalMin(e.target.value)} - className="w-full border border-gray-300 rounded px-2 py-1 text-sm" - /> - - - setLocalMax(e.target.value)} - className="w-full border border-gray-300 rounded px-2 py-1 text-sm" - /> -
- -
- ); -} - -// Availability Filter -function AvailabilityFilterSection({ - filter, - selected, - onChange, -}: { - filter: AvailabilityFilter; - selected?: "in_stock" | "out_of_stock"; - onChange: (value?: "in_stock" | "out_of_stock") => void; -}) { - return ( -
- {filter.options.map((option) => ( - - ))} - {selected && ( - - )} -
- ); -} - -// Option Filter (Size, Color, etc.) -function OptionFilterSection({ - filter, - selectedValues, - onToggle, -}: { - filter: OptionFilter; - selectedValues: string[]; - onToggle: (id: string) => void; -}) { - return ( -
- {filter.options.map((option) => ( - - ))} -
- ); -} diff --git a/src/components/products/ProductListingLayout.tsx b/src/components/products/ProductListingLayout.tsx index 70f363df..79379c68 100644 --- a/src/components/products/ProductListingLayout.tsx +++ b/src/components/products/ProductListingLayout.tsx @@ -1,19 +1,13 @@ "use client"; import type { Product, ProductFiltersResponse } from "@spree/sdk"; +import type React from "react"; import type { RefObject } from "react"; -import { - CloseIcon, - FilterIcon, - SearchIcon, - SpinnerIcon, -} from "@/components/icons"; -import { - type ActiveFilters, - ProductFilters, -} from "@/components/products/ProductFilters"; +import { SearchIcon, SpinnerIcon } from "@/components/icons"; +import { FilterBar } from "@/components/products/filters"; import { ProductGrid } from "@/components/products/ProductGrid"; import { ProductGridSkeleton } from "@/components/products/ProductGridSkeleton"; +import type { ActiveFilters } from "@/types/filters"; interface ProductListingLayoutProps { products: Product[]; @@ -24,11 +18,9 @@ interface ProductListingLayoutProps { basePath: string; filtersData: ProductFiltersResponse | null; filtersLoading: boolean; - showMobileFilters: boolean; - setShowMobileFilters: (show: boolean) => void; + activeFilters: ActiveFilters; onFilterChange: (filters: ActiveFilters) => void; loadMoreRef: RefObject; - taxonId?: string; emptyMessage?: string; listId?: string; listName?: string; @@ -43,119 +35,61 @@ export function ProductListingLayout({ basePath, filtersData, filtersLoading, - showMobileFilters, - setShowMobileFilters, + activeFilters, onFilterChange, loadMoreRef, - taxonId, emptyMessage = "Try adjusting your filters", listId, listName, -}: ProductListingLayoutProps) { +}: ProductListingLayoutProps): React.ReactElement { return ( -
- {/* Mobile filter button */} -
- -
+
+ - {/* Mobile filter drawer */} - {showMobileFilters && ( -
-
setShowMobileFilters(false)} + {loading ? ( + + ) : products.length === 0 ? ( +
+ -
-
-

Filters

- -
-
- -
-
+

+ No products found +

+

{emptyMessage}

- )} - - {/* Desktop sidebar filters */} -
-
- + -
-
- {/* Products */} -
- {loading ? ( - - ) : products.length === 0 ? ( -
- -

- No products found -

-

{emptyMessage}

+
+ {loadingMore && ( +
+ + Loading more... +
+ )} + {!hasMore && products.length > 0 && ( +

No more products to load

+ )}
- ) : ( - <> -
-

- Showing {products.length} of {totalCount} products -

-
- - - - {/* Load more trigger */} -
- {loadingMore && ( -
- - Loading more... -
- )} - {!hasMore && products.length > 0 && ( -

- No more products to load -

- )} -
- - )} -
+ + )}
); } diff --git a/src/components/products/VariantPicker.tsx b/src/components/products/VariantPicker.tsx index a218c494..82ba51b1 100644 --- a/src/components/products/VariantPicker.tsx +++ b/src/components/products/VariantPicker.tsx @@ -2,6 +2,7 @@ import type { OptionType, Variant } from "@spree/sdk"; import { useMemo } from "react"; +import { isColorOption, resolveColor } from "@/lib/utils/color-map"; interface VariantPickerProps { variants: Variant[]; @@ -16,7 +17,6 @@ export function VariantPicker({ selectedVariant, onVariantChange, }: VariantPickerProps) { - // Build option values map by option type const optionValuesMap = useMemo(() => { const map: Record> = {}; @@ -35,7 +35,6 @@ export function VariantPicker({ return map; }, [variants, optionTypes]); - // Get selected options from current variant const selectedOptions = useMemo(() => { const options: Record = {}; if (selectedVariant) { @@ -46,7 +45,6 @@ export function VariantPicker({ return options; }, [selectedVariant]); - // Precompute variant lookup structures to avoid O(n) iteration per option value const { variantOptionMaps, optionValueDetailsMap } = useMemo(() => { const maps = variants.map((variant) => { const optionsMap: Record = {}; @@ -56,7 +54,6 @@ export function VariantPicker({ return { variant, optionsMap }; }); - // Build option value details index: "typeId:name" -> option value object const detailsMap: Record = {}; for (const variant of variants) { @@ -71,7 +68,6 @@ export function VariantPicker({ return { variantOptionMaps: maps, optionValueDetailsMap: detailsMap }; }, [variants]); - // Find variant matching selected options const findVariant = (newOptions: Record): Variant | null => { const optionCount = Object.keys(newOptions).length; return ( @@ -85,7 +81,6 @@ export function VariantPicker({ ); }; - // Check if an option value is available given current selections const isOptionAvailable = ( optionTypeId: string, optionValue: string, @@ -98,7 +93,6 @@ export function VariantPicker({ ); }; - // Check if a variant with these options is purchasable const isOptionPurchasable = ( optionTypeId: string, optionValue: string, @@ -119,7 +113,6 @@ export function VariantPicker({ onVariantChange(newVariant); }; - // Get option value details from precomputed map (O(1) lookup) const getOptionValueDetails = ( optionTypeId: string, optionValueName: string, @@ -136,7 +129,7 @@ export function VariantPicker({ {optionTypes.map((optionType) => { const values = Array.from(optionValuesMap[optionType.id] || []); const selectedValue = selectedOptions[optionType.id]; - const isColorOption = optionType.name.toLowerCase() === "color"; + const isColor = isColorOption(optionType.name); return (
@@ -152,8 +145,7 @@ export function VariantPicker({ )}
- {isColorOption ? ( - // Color swatches + {isColor ? (
{values.map((value) => { const optionValue = getOptionValueDetails( @@ -180,9 +172,7 @@ export function VariantPicker({ ${!isPurchasable && isAvailable ? "opacity-50" : ""} `} style={{ - backgroundColor: value - .toLowerCase() - .replace(/\s+/g, ""), + backgroundColor: resolveColor(value), }} > {!isPurchasable && isAvailable && ( @@ -195,7 +185,6 @@ export function VariantPicker({ })}
) : ( - // Regular option buttons
{values.map((value) => { const optionValue = getOptionValueDetails( diff --git a/src/components/products/filters/AvailabilityDropdownContent.tsx b/src/components/products/filters/AvailabilityDropdownContent.tsx new file mode 100644 index 00000000..0eefad31 --- /dev/null +++ b/src/components/products/filters/AvailabilityDropdownContent.tsx @@ -0,0 +1,49 @@ +import type { AvailabilityFilter } from "@spree/sdk"; +import { AVAILABILITY_LABELS } from "@/lib/utils/filters"; +import { type AvailabilityStatus, isAvailabilityStatus } from "@/types/filters"; + +interface AvailabilityDropdownContentProps { + filter: AvailabilityFilter; + selected?: AvailabilityStatus; + onChange: (value?: AvailabilityStatus) => void; +} + +export function AvailabilityDropdownContent({ + filter, + selected, + onChange, +}: AvailabilityDropdownContentProps) { + return ( +
+

Availability

+
    + {filter.options.map((option) => { + const isSelected = selected === option.id; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/products/filters/FilterChips.tsx b/src/components/products/filters/FilterChips.tsx new file mode 100644 index 00000000..a88b001e --- /dev/null +++ b/src/components/products/filters/FilterChips.tsx @@ -0,0 +1,105 @@ +"use client"; + +import type { OptionFilter, ProductFiltersResponse } from "@spree/sdk"; +import { CloseIcon } from "@/components/icons"; +import { AVAILABILITY_LABELS } from "@/lib/utils/filters"; +import { + findMatchingBucket, + type PriceBucket, +} from "@/lib/utils/price-buckets"; +import type { ActiveFilters } from "@/types/filters"; + +interface FilterChipsProps { + activeFilters: ActiveFilters; + filtersData: ProductFiltersResponse | null; + priceBuckets: PriceBucket[]; + onRemoveOptionValue: (optionValueId: string) => void; + onRemovePrice: () => void; + onRemoveAvailability: () => void; + onClearAll: () => void; +} + +export function FilterChips({ + activeFilters, + filtersData, + priceBuckets, + onRemoveOptionValue, + onRemovePrice, + onRemoveAvailability, + onClearAll, +}: FilterChipsProps) { + const chips: { key: string; label: string; onRemove: () => void }[] = []; + + if (filtersData) { + for (const optionValueId of activeFilters.optionValues) { + const optionFilter = filtersData.filters.find( + (f) => + f.type === "option" && + (f as OptionFilter).options.some((o) => o.id === optionValueId), + ) as OptionFilter | undefined; + + if (optionFilter) { + const option = optionFilter.options.find((o) => o.id === optionValueId); + if (option) { + chips.push({ + key: `option-${optionValueId}`, + label: `${optionFilter.presentation}: ${option.presentation}`, + onRemove: () => onRemoveOptionValue(optionValueId), + }); + } + } + } + } + + if ( + activeFilters.priceMin !== undefined || + activeFilters.priceMax !== undefined + ) { + const matchingBucket = findMatchingBucket( + priceBuckets, + activeFilters.priceMin, + activeFilters.priceMax, + ); + chips.push({ + key: "price", + label: `Price: ${matchingBucket?.label || "Custom"}`, + onRemove: onRemovePrice, + }); + } + + if (activeFilters.availability) { + chips.push({ + key: "availability", + label: `${AVAILABILITY_LABELS[activeFilters.availability] || activeFilters.availability}`, + onRemove: onRemoveAvailability, + }); + } + + if (chips.length === 0) return null; + + return ( +
+ {chips.map((chip) => ( + + {chip.label} + + + ))} + +
+ ); +} diff --git a/src/components/products/filters/FilterDropdown.tsx b/src/components/products/filters/FilterDropdown.tsx new file mode 100644 index 00000000..32782769 --- /dev/null +++ b/src/components/products/filters/FilterDropdown.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { ChevronDownIcon } from "@/components/icons"; + +interface FilterDropdownProps { + label: string; + badgeCount?: number; + isOpen: boolean; + onToggle: () => void; + onClose: () => void; + children: React.ReactNode; + align?: "left" | "right"; +} + +export function FilterDropdown({ + label, + badgeCount, + isOpen, + onToggle, + onClose, + children, + align = "left", +}: FilterDropdownProps) { + const containerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + function handleClickOutside(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + onClose(); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, onClose]); + + const hasActive = badgeCount !== undefined && badgeCount > 0; + + return ( +
+ + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/components/products/filters/MobileFilterDrawer.tsx b/src/components/products/filters/MobileFilterDrawer.tsx new file mode 100644 index 00000000..082198a4 --- /dev/null +++ b/src/components/products/filters/MobileFilterDrawer.tsx @@ -0,0 +1,387 @@ +"use client"; + +import type { + AvailabilityFilter, + OptionFilter, + ProductFiltersResponse, +} from "@spree/sdk"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CheckIcon, CloseIcon } from "@/components/icons"; +import { isColorOption, resolveColor } from "@/lib/utils/color-map"; +import { AVAILABILITY_LABELS, getActiveFilterCount } from "@/lib/utils/filters"; +import type { PriceBucket } from "@/lib/utils/price-buckets"; +import { findMatchingBucket } from "@/lib/utils/price-buckets"; +import { + type ActiveFilters, + type AvailabilityStatus, + isAvailabilityStatus, +} from "@/types/filters"; + +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +interface MobileFilterDrawerProps { + isOpen: boolean; + onClose: () => void; + filtersData: ProductFiltersResponse | null; + activeFilters: ActiveFilters; + priceBuckets: PriceBucket[]; + onApply: (filters: ActiveFilters) => void; +} + +export function MobileFilterDrawer({ + isOpen, + onClose, + filtersData, + activeFilters, + priceBuckets, + onApply, +}: MobileFilterDrawerProps) { + const drawerRef = useRef(null); + const closeButtonRef = useRef(null); + const triggerRef = useRef(null); + + const [stagedFilters, setStagedFilters] = + useState(activeFilters); + + useEffect(() => { + if (isOpen) { + setStagedFilters(activeFilters); + triggerRef.current = document.activeElement; + closeButtonRef.current?.focus(); + } else if (triggerRef.current instanceof HTMLElement) { + triggerRef.current.focus(); + triggerRef.current = null; + } + }, [isOpen, activeFilters]); + + const handleOptionValueToggle = useCallback((optionValueId: string) => { + setStagedFilters((prev) => { + const newOptionValues = prev.optionValues.includes(optionValueId) + ? prev.optionValues.filter((id) => id !== optionValueId) + : [...prev.optionValues, optionValueId]; + return { ...prev, optionValues: newOptionValues }; + }); + }, []); + + const handlePriceChange = useCallback((min?: number, max?: number) => { + setStagedFilters((prev) => ({ ...prev, priceMin: min, priceMax: max })); + }, []); + + const handleAvailabilityChange = useCallback((value?: AvailabilityStatus) => { + setStagedFilters((prev) => ({ ...prev, availability: value })); + }, []); + + const handleClearAll = useCallback(() => { + setStagedFilters((prev) => ({ + optionValues: [], + priceMin: undefined, + priceMax: undefined, + availability: undefined, + sortBy: prev.sortBy, + })); + }, []); + + const handleApply = useCallback(() => { + onApply(stagedFilters); + onClose(); + }, [stagedFilters, onApply, onClose]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab" || !drawerRef.current) return; + + const focusable = drawerRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusable.length === 0) return; + + const first = focusable[0] as HTMLElement; + const last = focusable[focusable.length - 1] as HTMLElement; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }, + [onClose], + ); + + if (!isOpen) return null; + + const stagedCount = getActiveFilterCount(stagedFilters); + + return ( +
+
+
+
+ +

Filters

+
+
+ +
+ {filtersData?.filters.map((filter) => { + switch (filter.type) { + case "option": + return ( + + ); + case "price_range": + return ( + + ); + case "availability": + return ( + + ); + default: + return null; + } + })} +
+ +
+ {stagedCount > 0 && ( + + )} + +
+
+
+ ); +} + +function MobileOptionSection({ + filter, + selectedValues, + onToggle, +}: { + filter: OptionFilter; + selectedValues: string[]; + onToggle: (id: string) => void; +}) { + const isColorFilter = isColorOption(filter.presentation); + + return ( +
+

+ {filter.presentation} +

+ {isColorFilter ? ( +
+ {filter.options.map((option) => { + const isSelected = selectedValues.includes(option.id); + return ( + + ); + })} +
+ ) : ( +
+ {filter.options.map((option) => { + const isSelected = selectedValues.includes(option.id); + return ( + + ); + })} +
+ )} +
+ ); +} + +function MobilePriceSection({ + priceBuckets, + activeFilters, + onPriceChange, +}: { + priceBuckets: PriceBucket[]; + activeFilters: ActiveFilters; + onPriceChange: (min?: number, max?: number) => void; +}) { + if (priceBuckets.length === 0) return null; + + const selectedBucket = findMatchingBucket( + priceBuckets, + activeFilters.priceMin, + activeFilters.priceMax, + ); + + return ( +
+

+ Price +

+
+ {priceBuckets.map((bucket) => { + const isSelected = selectedBucket?.id === bucket.id; + return ( + + ); + })} +
+
+ ); +} + +function MobileAvailabilitySection({ + filter, + selected, + onChange, +}: { + filter: AvailabilityFilter; + selected?: AvailabilityStatus; + onChange: (value?: AvailabilityStatus) => void; +}) { + return ( +
+

+ Availability +

+
+ {filter.options.map((option) => { + const isSelected = selected === option.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/products/filters/OptionDropdownContent.tsx b/src/components/products/filters/OptionDropdownContent.tsx new file mode 100644 index 00000000..8f53ce07 --- /dev/null +++ b/src/components/products/filters/OptionDropdownContent.tsx @@ -0,0 +1,52 @@ +import type { OptionFilter } from "@spree/sdk"; +import { isColorOption, resolveColor } from "@/lib/utils/color-map"; + +interface OptionDropdownContentProps { + filter: OptionFilter; + selectedValues: string[]; + onToggle: (id: string) => void; +} + +export function OptionDropdownContent({ + filter, + selectedValues, + onToggle, +}: OptionDropdownContentProps) { + const isColorFilter = isColorOption(filter.presentation); + + return ( +
+

+ {filter.presentation} +

+
    + {filter.options.map((option) => { + const isSelected = selectedValues.includes(option.id); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/products/filters/PriceDropdownContent.tsx b/src/components/products/filters/PriceDropdownContent.tsx new file mode 100644 index 00000000..4a408ccb --- /dev/null +++ b/src/components/products/filters/PriceDropdownContent.tsx @@ -0,0 +1,52 @@ +import type { PriceBucket } from "@/lib/utils/price-buckets"; +import { findMatchingBucket } from "@/lib/utils/price-buckets"; +import type { ActiveFilters } from "@/types/filters"; + +interface PriceDropdownContentProps { + priceBuckets: PriceBucket[]; + activeFilters: ActiveFilters; + onPriceChange: (min?: number, max?: number) => void; +} + +export function PriceDropdownContent({ + priceBuckets, + activeFilters, + onPriceChange, +}: PriceDropdownContentProps) { + const selectedBucket = findMatchingBucket( + priceBuckets, + activeFilters.priceMin, + activeFilters.priceMax, + ); + + return ( +
+

Price Range

+
    + {priceBuckets.map((bucket) => { + const isSelected = selectedBucket?.id === bucket.id; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/products/filters/ProductFilters.tsx b/src/components/products/filters/ProductFilters.tsx new file mode 100644 index 00000000..277929df --- /dev/null +++ b/src/components/products/filters/ProductFilters.tsx @@ -0,0 +1,287 @@ +"use client"; + +import type { + AvailabilityFilter, + OptionFilter, + PriceRangeFilter, + ProductFiltersResponse, +} from "@spree/sdk"; +import type { JSX } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; +import { FilterIcon } from "@/components/icons"; +import { AvailabilityDropdownContent } from "@/components/products/filters/AvailabilityDropdownContent"; +import { FilterChips } from "@/components/products/filters/FilterChips"; +import { FilterDropdown } from "@/components/products/filters/FilterDropdown"; +import { MobileFilterDrawer } from "@/components/products/filters/MobileFilterDrawer"; +import { OptionDropdownContent } from "@/components/products/filters/OptionDropdownContent"; +import { PriceDropdownContent } from "@/components/products/filters/PriceDropdownContent"; +import { SortDropdownContent } from "@/components/products/filters/SortDropdownContent"; +import { getActiveFilterCount } from "@/lib/utils/filters"; +import { generatePriceBuckets } from "@/lib/utils/price-buckets"; +import type { ActiveFilters, AvailabilityStatus } from "@/types/filters"; + +interface FilterBarProps { + filtersData: ProductFiltersResponse | null; + filtersLoading: boolean; + activeFilters: ActiveFilters; + totalCount: number; + onFilterChange: (filters: ActiveFilters) => void; +} + +export const FilterBar = memo(function FilterBar({ + filtersData, + filtersLoading, + activeFilters, + totalCount, + onFilterChange, +}: FilterBarProps): JSX.Element | null { + const [openDropdownId, setOpenDropdownId] = useState(null); + const [showMobileDrawer, setShowMobileDrawer] = useState(false); + + const toggleDropdown = useCallback((id: string) => { + setOpenDropdownId((prev) => (prev === id ? null : id)); + }, []); + + const closeDropdown = useCallback(() => { + setOpenDropdownId(null); + }, []); + + const handleOptionValueToggle = useCallback( + (optionValueId: string) => { + const newOptionValues = activeFilters.optionValues.includes(optionValueId) + ? activeFilters.optionValues.filter((id) => id !== optionValueId) + : [...activeFilters.optionValues, optionValueId]; + onFilterChange({ ...activeFilters, optionValues: newOptionValues }); + }, + [activeFilters, onFilterChange], + ); + + const handlePriceChange = useCallback( + (min?: number, max?: number) => { + onFilterChange({ ...activeFilters, priceMin: min, priceMax: max }); + }, + [activeFilters, onFilterChange], + ); + + const handleAvailabilityChange = useCallback( + (availability?: AvailabilityStatus) => { + onFilterChange({ ...activeFilters, availability }); + }, + [activeFilters, onFilterChange], + ); + + const handleSortChange = useCallback( + (sortBy: string) => { + onFilterChange({ ...activeFilters, sortBy }); + closeDropdown(); + }, + [activeFilters, onFilterChange, closeDropdown], + ); + + const clearFilters = useCallback(() => { + onFilterChange({ + optionValues: [], + priceMin: undefined, + priceMax: undefined, + availability: undefined, + sortBy: activeFilters.sortBy, + }); + }, [onFilterChange, activeFilters.sortBy]); + + const priceBuckets = useMemo(() => { + if (!filtersData) return []; + const priceFilter = filtersData.filters.find( + (f) => f.type === "price_range", + ) as PriceRangeFilter | undefined; + if (!priceFilter) return []; + return generatePriceBuckets( + priceFilter.min, + priceFilter.max, + priceFilter.currency, + ); + }, [filtersData]); + + const optionFilters = useMemo(() => { + if (!filtersData) return []; + return filtersData.filters.filter( + (f) => f.type === "option", + ) as OptionFilter[]; + }, [filtersData]); + + const badgeCounts = useMemo(() => { + const counts: Record = {}; + for (const filter of optionFilters) { + counts[filter.id] = filter.options.filter((o) => + activeFilters.optionValues.includes(o.id), + ).length; + } + return counts; + }, [optionFilters, activeFilters.optionValues]); + + const priceBadge = + activeFilters.priceMin !== undefined || activeFilters.priceMax !== undefined + ? 1 + : 0; + + const availabilityBadge = activeFilters.availability ? 1 : 0; + + const totalActiveFilters = getActiveFilterCount(activeFilters); + + const hasActiveFilters = totalActiveFilters > 0; + + const activeSortBy = activeFilters.sortBy || filtersData?.default_sort; + + if (filtersLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!filtersData) return null; + + const availabilityFilter = filtersData.filters.find( + (f) => f.type === "availability", + ) as AvailabilityFilter | undefined; + + const hasPriceFilter = + filtersData.filters.some((f) => f.type === "price_range") && + priceBuckets.length > 0; + + return ( +
+
+
+ {optionFilters.map((filter) => ( + toggleDropdown(filter.id)} + onClose={closeDropdown} + > + + + ))} + + {hasPriceFilter && ( + toggleDropdown("price")} + onClose={closeDropdown} + > + + + )} + + {availabilityFilter && ( + toggleDropdown("availability")} + onClose={closeDropdown} + > + + + )} +
+ +
+ + {totalCount} {totalCount === 1 ? "product" : "products"} + + toggleDropdown("sort")} + onClose={closeDropdown} + align="right" + > + + +
+
+ +
+ + +
+ toggleDropdown("sort-mobile")} + onClose={closeDropdown} + align="right" + > + + +
+
+ + {hasActiveFilters && ( + handleOptionValueToggle(id)} + onRemovePrice={() => handlePriceChange(undefined, undefined)} + onRemoveAvailability={() => handleAvailabilityChange(undefined)} + onClearAll={clearFilters} + /> + )} + + setShowMobileDrawer(false)} + filtersData={filtersData} + activeFilters={activeFilters} + priceBuckets={priceBuckets} + onApply={onFilterChange} + /> +
+ ); +}); diff --git a/src/components/products/filters/SortDropdownContent.tsx b/src/components/products/filters/SortDropdownContent.tsx new file mode 100644 index 00000000..dbbb945a --- /dev/null +++ b/src/components/products/filters/SortDropdownContent.tsx @@ -0,0 +1,40 @@ +import { getSortLabel } from "@/lib/utils/filters"; + +interface SortDropdownContentProps { + sortOptions: { id: string }[]; + activeSortBy?: string; + onSortChange: (sortBy: string) => void; +} + +export function SortDropdownContent({ + sortOptions, + activeSortBy, + onSortChange, +}: SortDropdownContentProps) { + return ( +
    + {sortOptions.map((option) => { + const isActive = activeSortBy === option.id; + return ( +
  • + +
  • + ); + })} +
+ ); +} diff --git a/src/components/products/filters/index.ts b/src/components/products/filters/index.ts new file mode 100644 index 00000000..085400b5 --- /dev/null +++ b/src/components/products/filters/index.ts @@ -0,0 +1,4 @@ +export { FilterChips } from "./FilterChips"; +export { FilterDropdown } from "./FilterDropdown"; +export { MobileFilterDrawer } from "./MobileFilterDrawer"; +export { FilterBar } from "./ProductFilters"; diff --git a/src/hooks/useProductListing.ts b/src/hooks/useProductListing.ts index 0cbb04b1..ff135116 100644 --- a/src/hooks/useProductListing.ts +++ b/src/hooks/useProductListing.ts @@ -7,30 +7,14 @@ import type { ProductListParams, } from "@spree/sdk"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ActiveFilters } from "@/components/products/ProductFilters"; import { getProductFilters } from "@/lib/data/products"; +import { filtersEqual } from "@/lib/utils/filters"; import { buildProductQueryParams } from "@/lib/utils/product-query"; - -/** Shallow compare two ActiveFilters objects. */ -function filtersEqual(a: ActiveFilters, b: ActiveFilters): boolean { - if (a.priceMin !== b.priceMin || a.priceMax !== b.priceMax) return false; - if (a.availability !== b.availability) return false; - if (a.sortBy !== b.sortBy) return false; - if (a.optionValues.length !== b.optionValues.length) return false; - const aVals = [...a.optionValues].sort(); - const bVals = [...b.optionValues].sort(); - for (let i = 0; i < aVals.length; i++) { - if (aVals[i] !== bVals[i]) return false; - } - return true; -} +import type { ActiveFilters } from "@/types/filters"; interface UseProductListingOptions { - /** Function that fetches a page of products given query params. */ fetchFn: (params: ProductListParams) => Promise>; - /** Optional params passed to getProductFilters (e.g. { taxon_id }). */ filterParams?: ProductListParams; - /** Optional search query string. */ searchQuery?: string; } @@ -51,10 +35,10 @@ export function useProductListing({ null, ); const [filtersLoading, setFiltersLoading] = useState(true); - const [showMobileFilters, setShowMobileFilters] = useState(false); const loadMoreRef = useRef(null); const pageRef = useRef(1); const hasMoreRef = useRef(false); + const loadingMoreRef = useRef(false); const filtersRef = useRef({ optionValues: [] }); const filterParamsRef = useRef(filterParams); filterParamsRef.current = filterParams; @@ -102,11 +86,8 @@ export function useProductListing({ [fetchProducts], ); - // Fetch filters (scoped to search query when present) + // biome-ignore lint/correctness/useExhaustiveDependencies: filterParamsKey triggers re-fetch on soft-nav useEffect(() => { - // Track filterParams changes for re-fetching on soft-nav - void filterParamsKey; - let cancelled = false; const fetchFilters = async () => { @@ -136,10 +117,8 @@ export function useProductListing({ }; }, [searchQuery, filterParamsKey]); - // Load products when search query or filter params change + // biome-ignore lint/correctness/useExhaustiveDependencies: filterParamsKey triggers re-fetch on soft-nav useEffect(() => { - // Track filterParams changes for re-fetching on soft-nav - void filterParamsKey; loadProducts(filtersRef.current, searchQuery); }, [searchQuery, loadProducts, filterParamsKey]); @@ -155,33 +134,46 @@ export function useProductListing({ ); const loadMore = useCallback(async () => { - if (loadingMore || !hasMoreRef.current) return; + if (loadingMoreRef.current || !hasMoreRef.current) return; + loadingMoreRef.current = true; setLoadingMore(true); const currentLoadId = loadIdRef.current; const nextPage = pageRef.current + 1; - const response = await fetchProducts(nextPage, activeFilters, searchQuery); + const response = await fetchProducts( + nextPage, + filtersRef.current, + searchQueryRef.current, + ); if (response && loadIdRef.current === currentLoadId) { - setProducts((prev) => [...prev, ...response.data]); + setProducts((prev) => { + const existingIds = new Set(prev.map((p) => p.id)); + const newProducts = response.data.filter((p) => !existingIds.has(p.id)); + return [...prev, ...newProducts]; + }); const moreAvailable = nextPage < response.meta.pages; setHasMore(moreAvailable); hasMoreRef.current = moreAvailable; pageRef.current = nextPage; } + loadingMoreRef.current = false; setLoadingMore(false); - }, [fetchProducts, loadingMore, activeFilters, searchQuery]); + }, [fetchProducts]); - // Infinite scroll observer useEffect(() => { const currentRef = loadMoreRef.current; if (!currentRef || loading) return; const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasMoreRef.current && !loadingMore) { + if ( + entries[0].isIntersecting && + hasMoreRef.current && + !loadingMoreRef.current + ) { loadMore(); } }, @@ -193,7 +185,7 @@ export function useProductListing({ return () => { observer.disconnect(); }; - }, [loadMore, loading, loadingMore]); + }, [loadMore, loading]); return { products, @@ -201,10 +193,9 @@ export function useProductListing({ loadingMore, hasMore, totalCount, + activeFilters, filtersData, filtersLoading, - showMobileFilters, - setShowMobileFilters, handleFilterChange, loadMoreRef, }; diff --git a/src/lib/utils/color-map.ts b/src/lib/utils/color-map.ts new file mode 100644 index 00000000..8959faa8 --- /dev/null +++ b/src/lib/utils/color-map.ts @@ -0,0 +1,140 @@ +const COLOR_MAP: Record = { + ecru: "#C2B280", + cream: "#FFFDD0", + ivory: "#FFFFF0", + offwhite: "#FAF9F6", + champagne: "#F7E7CE", + sand: "#C2B280", + taupe: "#483C32", + charcoal: "#36454F", + graphite: "#41424C", + anthracite: "#293133", + ash: "#B2BEB5", + slate: "#708090", + stone: "#928E85", + camel: "#C19A6B", + cognac: "#9A463D", + chocolate: "#7B3F00", + espresso: "#4E312D", + mocha: "#967969", + chestnut: "#954535", + rust: "#B7410E", + terracotta: "#E2725B", + cinnamon: "#D2691E", + copper: "#B87333", + bronze: "#CD7F32", + + burgundy: "#800020", + wine: "#722F37", + bordeaux: "#5C0120", + oxblood: "#4A0000", + cranberry: "#9B1B30", + cherry: "#DE3163", + scarlet: "#FF2400", + cardinal: "#C41E3A", + ruby: "#E0115F", + raspberry: "#E30B5C", + strawberry: "#FC5A8D", + blush: "#DE5D83", + rose: "#FF007F", + dustyrose: "#DCAE96", + mauve: "#E0B0FF", + fuchsia: "#FF00FF", + magenta: "#FF0055", + bubblegum: "#FFC1CC", + flamingo: "#FC8EAC", + + navy: "#000080", + cobalt: "#0047AB", + royal: "#4169E1", + sapphire: "#0F52BA", + cerulean: "#007BA7", + denim: "#1560BD", + sky: "#87CEEB", + baby: "#89CFF0", + babyblue: "#89CFF0", + powder: "#B0E0E6", + powderblue: "#B0E0E6", + ice: "#D6ECEF", + petrol: "#005F6A", + steel: "#4682B4", + midnight: "#191970", + electric: "#7DF9FF", + indigo: "#4B0082", + + mint: "#98FF98", + sage: "#BCB88A", + olive: "#808000", + emerald: "#50C878", + forest: "#228B22", + hunter: "#355E3B", + jade: "#00A86B", + moss: "#8A9A5B", + pine: "#01796F", + pistachio: "#93C572", + lime: "#32CD32", + neon: "#39FF14", + seafoam: "#93E9BE", + eucalyptus: "#44D7A8", + army: "#4B5320", + avocado: "#568203", + + mustard: "#FFDB58", + gold: "#FFD700", + honey: "#EB9605", + amber: "#FFBF00", + marigold: "#EAA221", + saffron: "#F4C430", + peach: "#FFCBA4", + apricot: "#FBCEB1", + tangerine: "#FF9966", + pumpkin: "#FF7518", + papaya: "#FFEFD5", + mango: "#FF8243", + turmeric: "#E3A857", + butterscotch: "#E09540", + caramel: "#FFD59A", + lemon: "#FFF44F", + canary: "#FFEF00", + banana: "#FFE135", + + lila: "#C8A2C8", + lilac: "#C8A2C8", + lavender: "#E6E6FA", + plum: "#8E4585", + eggplant: "#614051", + aubergine: "#614051", + amethyst: "#9966CC", + grape: "#6F2DA8", + orchid: "#DA70D6", + mulberry: "#C54B8C", + wisteria: "#C9A0DC", + periwinkle: "#CCCCFF", + heather: "#B7C3D0", + thistle: "#D8BFD8", + violet: "#8F00FF", + + coral: "#FF7F50", + salmon: "#FA8072", + brick: "#CB4154", + pewter: "#8BA8B7", + oatmeal: "#D3C4A2", + linen: "#FAF0E6", + pearl: "#EAE0C8", + platinum: "#E5E4E2", + titanium: "#878681", + gunmetal: "#2A3439", + obsidian: "#3B3B3B", + onyx: "#353839", + jet: "#343434", +}; + +export function resolveColor(name: string): string { + const key = name.toLowerCase().replace(/\s+/g, ""); + return COLOR_MAP[key] || key; +} + +export function isColorOption(name: string): boolean { + const lower = name.toLowerCase(); + return lower === "color" || lower === "colour"; +} diff --git a/src/lib/utils/filters.ts b/src/lib/utils/filters.ts new file mode 100644 index 00000000..f9e5cfa4 --- /dev/null +++ b/src/lib/utils/filters.ts @@ -0,0 +1,53 @@ +import type { ActiveFilters } from "@/types/filters"; + +export function filtersEqual(a: ActiveFilters, b: ActiveFilters): boolean { + if (a.priceMin !== b.priceMin || a.priceMax !== b.priceMax) return false; + if (a.availability !== b.availability) return false; + if (a.sortBy !== b.sortBy) return false; + if (a.optionValues.length !== b.optionValues.length) return false; + const aVals = [...a.optionValues].sort(); + const bVals = [...b.optionValues].sort(); + for (let i = 0; i < aVals.length; i++) { + if (aVals[i] !== bVals[i]) return false; + } + return true; +} + +export function getActiveFilterCount(filters: ActiveFilters): number { + return ( + filters.optionValues.length + + (filters.priceMin !== undefined || filters.priceMax !== undefined ? 1 : 0) + + (filters.availability ? 1 : 0) + ); +} + +const SORT_LABELS_CANONICAL: Record = { + manual: "Manual", + best_selling: "Best Selling", + price: "Price: Low to High", + "-price": "Price: High to Low", + "-available_on": "Newest", + available_on: "Oldest", + name: "Name (A-Z)", + "-name": "Name (Z-A)", +}; + +export function normalizeSortKey(key: string): string { + if (key in SORT_LABELS_CANONICAL) return key; + const match = key.match(/^(\w+)\s+(asc|desc)$/); + if (!match) return key; + const [, field, direction] = match; + const needsNegation = + (field === "available_on" && direction === "desc") || + (field !== "available_on" && direction === "desc"); + return needsNegation ? `-${field}` : field; +} + +export function getSortLabel(key: string): string { + return SORT_LABELS_CANONICAL[normalizeSortKey(key)] || key; +} + +export const AVAILABILITY_LABELS: Record = { + in_stock: "In Stock", + out_of_stock: "Out of Stock", +}; diff --git a/src/lib/utils/price-buckets.ts b/src/lib/utils/price-buckets.ts new file mode 100644 index 00000000..648cae3f --- /dev/null +++ b/src/lib/utils/price-buckets.ts @@ -0,0 +1,71 @@ +export interface PriceBucket { + id: string; + label: string; + min?: number; + max?: number; +} + +const THRESHOLDS = [50, 100, 200]; + +function formatCurrency(amount: number, currency: string): string { + try { + return new Intl.NumberFormat("en", { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + } catch { + return `${currency} ${amount}`; + } +} + +export function generatePriceBuckets( + filterMin: number, + filterMax: number, + currency: string, +): PriceBucket[] { + const buckets: PriceBucket[] = []; + + if (filterMin < THRESHOLDS[0]) { + buckets.push({ + id: `under-${THRESHOLDS[0]}`, + label: `Under ${formatCurrency(THRESHOLDS[0], currency)}`, + max: THRESHOLDS[0], + }); + } + + for (let i = 0; i < THRESHOLDS.length - 1; i++) { + if (filterMax > THRESHOLDS[i] && filterMin < THRESHOLDS[i + 1]) { + buckets.push({ + id: `${THRESHOLDS[i]}-${THRESHOLDS[i + 1]}`, + label: `${formatCurrency(THRESHOLDS[i], currency)} - ${formatCurrency(THRESHOLDS[i + 1], currency)}`, + min: THRESHOLDS[i], + max: THRESHOLDS[i + 1], + }); + } + } + + const lastThreshold = THRESHOLDS[THRESHOLDS.length - 1]; + if (filterMax > lastThreshold) { + buckets.push({ + id: `${lastThreshold}-plus`, + label: `${formatCurrency(lastThreshold, currency)}+`, + min: lastThreshold, + }); + } + + return buckets; +} + +export function findMatchingBucket( + buckets: PriceBucket[], + priceMin?: number, + priceMax?: number, +): PriceBucket | undefined { + return buckets.find( + (b) => + (b.min === undefined ? priceMin === undefined : b.min === priceMin) && + (b.max === undefined ? priceMax === undefined : b.max === priceMax), + ); +} diff --git a/src/lib/utils/product-query.ts b/src/lib/utils/product-query.ts index 8fc96370..3227404f 100644 --- a/src/lib/utils/product-query.ts +++ b/src/lib/utils/product-query.ts @@ -1,5 +1,5 @@ import type { ProductListParams } from "@spree/sdk"; -import type { ActiveFilters } from "@/components/products/ProductFilters"; +import type { ActiveFilters } from "@/types/filters"; /** * Build query params from active product filters. diff --git a/src/types/filters.ts b/src/types/filters.ts new file mode 100644 index 00000000..4e46e6df --- /dev/null +++ b/src/types/filters.ts @@ -0,0 +1,20 @@ +export type AvailabilityStatus = "in_stock" | "out_of_stock"; + +export const AVAILABILITY_STATUSES: ReadonlySet = new Set([ + "in_stock", + "out_of_stock", +]); + +export function isAvailabilityStatus( + value: string, +): value is AvailabilityStatus { + return AVAILABILITY_STATUSES.has(value); +} + +export interface ActiveFilters { + priceMin?: number; + priceMax?: number; + optionValues: string[]; + availability?: AvailabilityStatus; + sortBy?: string; +}