diff --git a/backend/src/output.txt b/backend/src/output.txt new file mode 100644 index 0000000..5d8ebd1 --- /dev/null +++ b/backend/src/output.txt @@ -0,0 +1,32 @@ +C:\Users\USER\OneDrive\Desktop\Open Source Projects\Stellar_Payment_API\backend\src +├── app.js +├── index.js +├── lib +| ├── auth.js +| ├── auth.test.js +| ├── branding.js +| ├── create-payment-rate-limit.js +| ├── create-payment-rate-limit.test.js +| ├── db.js +| ├── env-validation.js +| ├── idempotency.js +| ├── log-retention.js +| ├── log-retention.test.js +| ├── rate-limit.js +| ├── rate-limit.test.js +| ├── redis.js +| ├── request-schemas.js +| ├── request-schemas.test.js +| ├── stellar.js +| ├── stellar.test.js +| ├── supabase.js +| ├── validate-uuid.js +| └── webhooks.js +├── routes +| ├── merchants.js +| ├── metrics.js +| └── payments.js +└── server.js + +directory: 2 file: 26 + diff --git a/frontend/output.txt b/frontend/output.txt new file mode 100644 index 0000000..2dbd29d --- /dev/null +++ b/frontend/output.txt @@ -0,0 +1,58 @@ +C:\Users\USER\OneDrive\Desktop\Open Source Projects\Stellar_Payment_API\frontend +├── content +| └── docs +├── instrumentation.ts +├── messages +| ├── en.json +| ├── es.json +| └── pt.json +├── next-env.d.ts +├── next.config.js +├── package-lock.json +├── package.json +├── PAY_WITH_WALLET.md +├── playwright.config.ts +├── pnpm-lock.yaml +├── postcss.config.js +├── public +| ├── icons +| ├── manifest.json +| ├── sw.js +| ├── workbox-3c9d0171.js +| └── workbox-f1770938.js +├── sentry.client.config.ts +├── sentry.server.config.ts +├── SENTRY_SETUP.md +├── src +| ├── app +| ├── components +| ├── hooks +| ├── i18n +| ├── lib +| ├── types +| ├── utils +| └── vitest-setup-types.d.ts +├── tailwind.config.js +├── test-mdx.js +├── test-results +| ├── activity-feed-hover-Activi-229ee-ork-for-keyboard-navigation-desktop-chrome +| ├── activity-feed-hover-Activi-229ee-ork-for-keyboard-navigation-mobile-chrome +| ├── activity-feed-hover-Activi-3e5d2-es-apply-Pluto-theme-colors-desktop-chrome +| ├── activity-feed-hover-Activi-3e5d2-es-apply-Pluto-theme-colors-mobile-chrome +| ├── activity-feed-hover-Activi-8b513-pply-theme-colors-and-scale-desktop-chrome +| ├── activity-feed-hover-Activi-8b513-pply-theme-colors-and-scale-mobile-chrome +| ├── activity-feed-hover-Activi-8d327--for-interactivity-feedback-desktop-chrome +| ├── activity-feed-hover-Activi-8d327--for-interactivity-feedback-mobile-chrome +| ├── activity-feed-hover-Activi-b53ff-es-apply-Pluto-theme-colors-desktop-chrome +| ├── activity-feed-hover-Activi-b53ff-es-apply-Pluto-theme-colors-mobile-chrome +| ├── activity-feed-hover-Activi-d1151-pply-theme-colors-and-scale-desktop-chrome +| └── activity-feed-hover-Activi-d1151-pply-theme-colors-and-scale-mobile-chrome +├── tests +| ├── e2e +| └── transaction-history-hover.spec.ts +├── tsconfig.json +├── tsconfig.tsbuildinfo +└── vitest.config.ts + +directory: 28 file: 26 + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92e6367..2d6c210 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -232,6 +232,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", @@ -1812,6 +1813,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1860,6 +1862,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1922,29 +1925,6 @@ "node": ">=10" } }, - "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/@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/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -2713,6 +2693,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2734,6 +2715,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz", "integrity": "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2746,6 +2728,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3266,6 +3249,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3282,6 +3266,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", @@ -3299,6 +3284,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" } @@ -3663,6 +3649,7 @@ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.58.2" }, @@ -4612,6 +4599,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5974,7 +5962,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -6071,8 +6058,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/canvas-confetti": { "version": "1.9.0", @@ -6185,7 +6171,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": "*" @@ -6196,7 +6181,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": "*" @@ -6237,8 +6221,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/json5": { "version": "0.0.29", @@ -6323,6 +6306,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -6334,6 +6318,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -6435,6 +6420,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -7883,7 +7869,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" @@ -7893,29 +7878,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", @@ -7926,15 +7907,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", @@ -7947,7 +7926,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" } @@ -7957,7 +7935,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" } @@ -7966,15 +7943,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", @@ -7991,7 +7966,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", @@ -8005,7 +7979,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", @@ -8018,7 +7991,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", @@ -8033,7 +8005,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" @@ -8043,15 +8014,13 @@ "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/abitype": { "version": "1.2.3", @@ -8079,6 +8048,7 @@ "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" }, @@ -8100,7 +8070,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" }, @@ -8151,7 +8120,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" }, @@ -8169,7 +8137,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8185,8 +8152,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/ansi-colors": { "version": "4.1.3", @@ -8820,6 +8786,7 @@ "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -8954,6 +8921,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9255,7 +9223,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" } @@ -9664,7 +9631,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -9836,21 +9804,6 @@ } } }, - "node_modules/data-urls/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/data-urls/node_modules/tr46": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", @@ -10127,7 +10080,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1527314.tgz", "integrity": "sha512-UohCFOlzpPPD/IcsxM0k4lVZp/GfhPVJ6l2No5XX+LknpGisPWJe17oOHQhZTHf6ThUFIMwHO6bSEZUq/6oP7w==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/didyoumean": { "version": "1.2.2", @@ -10161,8 +10115,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-helpers": { "version": "5.2.1", @@ -10301,7 +10254,6 @@ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" @@ -10607,6 +10559,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10776,6 +10729,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11858,8 +11812,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/glob/node_modules/brace-expansion": { "version": "2.0.2", @@ -12237,21 +12190,6 @@ } } }, - "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/http-link-header": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.3.tgz", @@ -12369,7 +12307,8 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/ieee754": { "version": "1.2.1", @@ -13195,7 +13134,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", @@ -13210,7 +13148,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" }, @@ -13227,6 +13164,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -13326,21 +13264,6 @@ } } }, - "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/jsdom/node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -13438,8 +13361,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -13673,6 +13595,7 @@ "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" }, @@ -13686,6 +13609,7 @@ "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, @@ -13712,6 +13636,7 @@ "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -14124,6 +14049,7 @@ "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" @@ -14151,6 +14077,7 @@ "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", @@ -14704,7 +14631,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" }, @@ -14802,7 +14728,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15176,8 +15101,7 @@ "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/merge2": { "version": "1.4.1", @@ -16112,8 +16036,7 @@ "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/netmask": { "version": "2.0.2", @@ -16130,6 +16053,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "14.2.35", "@swc/helpers": "0.5.5", @@ -16268,17 +16192,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next-mdx-remote": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/next-mdx-remote/-/next-mdx-remote-6.0.0.tgz", @@ -17130,6 +17043,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17340,7 +17254,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -17356,7 +17269,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -17369,8 +17281,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/prismjs": { "version": "1.30.0", @@ -17632,6 +17543,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -17659,6 +17571,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -18425,6 +18338,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -18595,7 +18509,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", @@ -18632,7 +18545,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" }, @@ -18644,8 +18556,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/selenium-webdriver": { "version": "4.39.0", @@ -18663,6 +18574,7 @@ } ], "license": "Apache-2.0", + "peer": true, "dependencies": { "@bazel/runfiles": "^6.5.0", "jszip": "^3.10.1", @@ -19631,7 +19543,6 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" }, @@ -19791,7 +19702,6 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -19961,6 +19871,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -20253,6 +20164,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20818,6 +20730,7 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -21024,7 +20937,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" @@ -21119,7 +21031,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" } @@ -21129,7 +21040,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" @@ -21143,7 +21053,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" } @@ -21449,6 +21358,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21507,6 +21417,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -21769,6 +21680,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21827,6 +21739,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -22239,6 +22152,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/frontend/src/app/(authenticated)/payment-history/page.tsx b/frontend/src/app/(authenticated)/payment-history/page.tsx index a325106..d4a3b6c 100644 --- a/frontend/src/app/(authenticated)/payment-history/page.tsx +++ b/frontend/src/app/(authenticated)/payment-history/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import Skeleton from "react-loading-skeleton"; @@ -16,14 +16,8 @@ import { useMerchantApiKey, useMerchantId, } from "@/lib/merchant-store"; -import { - buildPaymentHistorySearchParams, - DEFAULT_PAYMENT_HISTORY_FILTERS, - filtersFromSearchParams, - hasActivePaymentHistoryFilters, - type PaymentHistoryFilterKey, - paymentHistoryFiltersReducer, -} from "@/lib/payment-history-filters"; +import { buildPaymentHistorySearchParams } from "@/lib/payment-history-filters"; +import { useTransactionFilters } from "@/hooks/useTransactionFilters"; import { usePaymentSocket } from "@/lib/usePaymentSocket"; interface Payment { @@ -41,40 +35,6 @@ interface PaginatedResponse { } const LIMIT = 50; -const STATUS_OPTIONS = [ - "all", - "pending", - "confirmed", - "failed", - "refunded", -] as const; -const ASSET_OPTIONS = ["all", "XLM", "USDC"] as const; - -function toStatusLabel(t: ReturnType, status: string) { - return t.has(`statuses.${status}`) ? t(`statuses.${status}`) : status; -} - -function StatCard({ label, value, icon, color }: { label: string; value: number | string; icon: React.ReactNode; color: string }) { - const colorMap: Record = { - mint: "text-mint bg-mint/10", - green: "text-green-400 bg-green-500/10", - yellow: "text-yellow-400 bg-yellow-500/10", - red: "text-red-400 bg-red-500/10", - }; - return ( -
-
-
-

{label}

-

{value}

-
-
- {icon} -
-
-
- ); -} function StatusBadge({ status }: { status: string }) { const styles: Record = { @@ -84,7 +44,9 @@ function StatusBadge({ status }: { status: string }) { pending: "bg-yellow-500/20 text-yellow-400", }; return ( - + {status} ); @@ -101,25 +63,29 @@ export default function PaymentHistoryPage() { useHydrateMerchantStore(); - const activeFilters = useMemo( - () => filtersFromSearchParams(searchParams), - [searchParams], - ); - const [draftFilters, dispatchDraftFilters] = useReducer( - paymentHistoryFiltersReducer, - activeFilters, - ); - const hasActiveFilters = hasActivePaymentHistoryFilters(activeFilters); - const draftHasActiveFilters = useMemo( - () => hasActivePaymentHistoryFilters(draftFilters), - [draftFilters], + // ── Optimistic filter state (replaces manual useReducer + debounce) ───────── + const pushSearchParams = useCallback( + (params: URLSearchParams) => { + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, + [pathname, router], ); - const searchSyncPending = draftFilters.search !== activeFilters.search; + const { + filters, + searchSyncPending, + isFilterPending, + hasActiveFilters, + onFilterChange, + onClearFilter, + onClearAll, + } = useTransactionFilters(pushSearchParams, searchParams); + + // ── UI state ──────────────────────────────────────────────────────────────── const [payments, setPayments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const page = 1; const [totalCount, setTotalCount] = useState(0); const [selectedPayment, setSelectedPayment] = useState(null); const [hoveredPayment, setHoveredPayment] = useState(null); @@ -128,83 +94,23 @@ export default function PaymentHistoryPage() { const [isFilterOpen, setIsFilterOpen] = useState(false); const [flashedIds, setFlashedIds] = useState>(new Set()); - useEffect(() => { - dispatchDraftFilters({ type: "sync", filters: activeFilters }); - }, [activeFilters]); - + // ── Keyboard shortcut: Cmd/Ctrl+C copies hovered payment link ─────────────── useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Check for Cmd+C (Mac) or Ctrl+C (Windows/Linux) if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "c") { if (hoveredPayment) { e.preventDefault(); - const origin = - typeof window !== "undefined" ? window.location.origin : ""; - const link = `${origin}/pay/${hoveredPayment}`; - navigator.clipboard.writeText(link); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + navigator.clipboard.writeText(`${origin}/pay/${hoveredPayment}`); toast.success(t("linkCopied")); } } }; - window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [hoveredPayment, t]); - const updateFilters = useCallback( - (nextFilters: typeof activeFilters) => { - const params = buildPaymentHistorySearchParams(nextFilters); - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { - scroll: false, - }); - }, - [pathname, router], - ); - - useEffect(() => { - const timer = window.setTimeout(() => { - if (draftFilters.search !== activeFilters.search) { - updateFilters({ ...draftFilters }); - } - }, 300); - - return () => window.clearTimeout(timer); - }, [activeFilters.search, draftFilters, updateFilters]); - - const handleFilterChange = useCallback( - (key: PaymentHistoryFilterKey, value: string) => { - const nextFilters = paymentHistoryFiltersReducer(draftFilters, { - type: "set", - key, - value, - }); - dispatchDraftFilters({ type: "set", key, value }); - - if (key !== "search") { - updateFilters(nextFilters); - } - }, - [draftFilters, updateFilters], - ); - - const clearFilter = useCallback( - (key: PaymentHistoryFilterKey) => { - const nextFilters = paymentHistoryFiltersReducer(draftFilters, { - type: "clear", - key, - }); - dispatchDraftFilters({ type: "clear", key }); - updateFilters(nextFilters); - }, - [draftFilters, updateFilters], - ); - - const clearAllFilters = useCallback(() => { - dispatchDraftFilters({ type: "reset" }); - updateFilters(DEFAULT_PAYMENT_HISTORY_FILTERS); - }, [updateFilters]); - + // ── Real-time payment status updates via WebSocket ─────────────────────────── const handleConfirmed = useCallback( (event: { id: string; @@ -216,11 +122,7 @@ export default function PaymentHistoryPage() { confirmed_at: string; }) => { setPayments((prev) => - prev.map((payment) => - payment.id === event.id - ? { ...payment, status: "confirmed" } - : payment, - ), + prev.map((p) => (p.id === event.id ? { ...p, status: "confirmed" } : p)), ); setFlashedIds((prev) => new Set([...prev, event.id])); setTimeout(() => { @@ -236,6 +138,9 @@ export default function PaymentHistoryPage() { usePaymentSocket(merchantId, handleConfirmed); + // ── Fetch payments whenever committed URL filters change ───────────────────── + // NOTE: we fetch against `searchParams` (committed URL state), not the + // optimistic draft, so the server always sees consistent filter values. useEffect(() => { const controller = new AbortController(); @@ -252,33 +157,23 @@ export default function PaymentHistoryPage() { return; } - const apiUrl = - process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; - const params = buildPaymentHistorySearchParams(activeFilters); - params.set("page", page.toString()); + const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; + const params = new URLSearchParams(searchParams.toString()); + params.set("page", "1"); params.set("limit", LIMIT.toString()); - const response = await fetch( - `${apiUrl}/api/payments?${params.toString()}`, - { - headers: { - "x-api-key": apiKey, - }, - signal: controller.signal, - }, - ); + const response = await fetch(`${apiUrl}/api/payments?${params.toString()}`, { + headers: { "x-api-key": apiKey }, + signal: controller.signal, + }); - if (!response.ok) { - throw new Error(t("fetchFailed")); - } + if (!response.ok) throw new Error(t("fetchFailed")); const data: PaginatedResponse = await response.json(); setPayments(data.payments ?? []); setTotalCount(data.total_count ?? 0); } catch (err: unknown) { - if (err instanceof Error && err.name === "AbortError") { - return; - } + if (err instanceof Error && err.name === "AbortError") return; setError(err instanceof Error ? err.message : t("loadFailed")); } finally { setLoading(false); @@ -286,55 +181,34 @@ export default function PaymentHistoryPage() { } fetchPayments(); - return () => controller.abort(); - }, [activeFilters, apiKey, t]); + }, [searchParams, apiKey, t]); + // ── Handlers ───────────────────────────────────────────────────────────────── const handlePaymentClick = (paymentId: string) => { setSelectedPayment(paymentId); setIsSheetOpen(true); }; - const closeSheet = () => { - setIsSheetOpen(false); - setSelectedPayment(null); - }; - - const closeModal = () => { - setIsModalOpen(false); - setSelectedPayment(null); - }; - + // ── Loading state ───────────────────────────────────────────────────────────── if (loading) { return (
-

- History -

-

- Payment History -

-

- View and manage all your payment transactions -

+

History

+

Payment History

+

View and manage all your payment transactions

-
- {[...Array(4)].map((_, i) => ( - - ))} + {[...Array(4)].map((_, i) => )}
-
- {[...Array(6)].map((_, i) => ( - - ))} + {[...Array(6)].map((_, i) => )}
@@ -343,18 +217,8 @@ export default function PaymentHistoryPage() {
- - + +
@@ -365,136 +229,77 @@ export default function PaymentHistoryPage() { ); } + // ── Error state ─────────────────────────────────────────────────────────────── if (error) { return (
-

- History -

-

- Payment History -

-

- View and manage all your payment transactions -

+

History

+

Payment History

-
- - + +
- -
-

- Unable to Load Payments -

-

{error}

- -
+

Unable to Load Payments

+

{error}

+
); } + // ── Empty state (no payments at all, no filters active) ────────────────────── if (payments.length === 0 && !hasActiveFilters) { return (
-

- History -

-

- Payment History -

-

- View and manage all your payment transactions -

+

History

+

Payment History

+

View and manage all your payment transactions

-
- - + +
- -
-

- No payment history yet -

-

- Start accepting payments to see your transaction history here. -

-
+

No payment history yet

+

Start accepting payments to see your transaction history here.

); } + // ── Main render ─────────────────────────────────────────────────────────────── return (
{/* Header */}
-

- History -

-

- Payment History -

-

- View and manage all your payment transactions -

+

History

+

Payment History

+

View and manage all your payment transactions

+ {/* Mobile filter trigger */} + ({ - id: payment.id, - createdAt: payment.created_at, + transactions={payments.map((p) => ({ + id: p.id, + createdAt: p.created_at, type: "payment", - status: payment.status, - amount: String(payment.amount), - asset: payment.asset, + status: p.status, + amount: String(p.amount), + asset: p.asset, sourceAccount: "", destAccount: "", - hash: payment.id, - description: payment.description ?? "", + hash: p.id, + description: p.description ?? "", }))} disabled={loading} filename={`payment_history_${new Date().toISOString().slice(0, 10)}.csv`} @@ -529,516 +333,189 @@ export default function PaymentHistoryPage() {
+ {/* ── Filter sidebar — purely presentational, all state from hook ── */} setIsFilterOpen(false)} /> -
- {/* Stats Cards */} -
-
-
-
-

- Total Payments -

-

- {totalCount} -

-
-
- - - -
-
-
- -
-
-
-

- Confirmed -

-

- {payments.filter((p) => p.status === "confirmed").length} -

-
-
- - - -
-
-
- -
-
-
-

- Pending -

-

- {payments.filter((p) => p.status === "pending").length} -

-
-
- - - -
-
-
- -
-
-
-

- Failed -

-

- {payments.filter((p) => p.status === "failed").length} -

-
-
- - - -
-
-
-
- - {/* Filters */} -
-
-
- -
- - handleFilterChange("search", event.target.value) - } - placeholder="Search by ID or description..." - className="w-full rounded-xl border border-[#E8E8E8] bg-white py-2.5 pl-10 pr-4 text-sm text-[#0A0A0A] placeholder:text-[#6B6B6B] focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50" - /> - - - -
-
- -
-
- - -
- -
- - -
- -
- - - handleFilterChange("dateFrom", event.target.value) - } - className="rounded-xl border border-[#E8E8E8] bg-white px-3 py-2.5 text-sm text-[#0A0A0A] focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50 [color-scheme:dark]" - /> -
-
- - - handleFilterChange("dateTo", event.target.value) - } - className="rounded-xl border border-[#E8E8E8] bg-white px-3 py-2.5 text-sm text-[#0A0A0A] focus:border-mint/50 focus:outline-none focus:ring-1 focus:ring-mint/50 [color-scheme:dark]" - /> -
+ {/* ── Main content — dims while a filter transition is in flight ── */} +
+ {/* Stats cards */} +
+ {[ + { label: "Total Payments", value: totalCount, color: "text-mint", bg: "bg-mint/10", icon: }, + { label: "Confirmed", value: payments.filter((p) => p.status === "confirmed").length, color: "text-green-400", bg: "bg-green-500/10", icon: }, + { label: "Pending", value: payments.filter((p) => p.status === "pending").length, color: "text-yellow-400", bg: "bg-yellow-500/10", icon: }, + { label: "Failed", value: payments.filter((p) => p.status === "failed").length, color: "text-red-400", bg: "bg-red-500/10", icon: }, + ].map(({ label, value, color, bg, icon }) => ( +
+
+
+

{label}

+

{value}

+
+
+ {icon} +
+
+
+ ))}
-
-
- {draftHasActiveFilters && ( + {/* Active filter chips */} + {hasActiveFilters && (
- - Active Filters: - - {draftFilters.search && ( - - Search: "{draftFilters.search}" - )} - {draftFilters.status !== "all" && ( - - Status: {draftFilters.status} - )} - {draftFilters.asset !== "all" && ( - - Asset: {draftFilters.asset} - )} - {draftFilters.dateFrom && ( - - From: {draftFilters.dateFrom} - )} - {draftFilters.dateTo && ( - - To: {draftFilters.dateTo} - )} -
)} - {/* Main Content Area */} -
-
-

- {t("showingResults", { shown: payments.length, total: totalCount })} -

-
+ {/* Results count */} +
+

+ {t("showingResults", { shown: payments.length, total: totalCount })} +

+
- {payments.length === 0 ? ( -
-
- - - -
-

No payments found

-

Try adjusting your filters to find what you're looking for.

- {draftHasActiveFilters && ( - - )} + {/* Payment table / empty filtered state */} + {payments.length === 0 ? ( +
+
+ + +
- ) : ( -
-
- - - - - - - - - - - - {payments.map((payment) => ( - setHoveredPayment(payment.id)} - onMouseLeave={() => setHoveredPayment(null)} - onClick={() => handlePaymentClick(payment.id)} - className={`group cursor-pointer transition-all hover:bg-[#F9F9F9] ${flashedIds.has(payment.id) ? "bg-emerald-50" : ""}`} - > - - - - - - +

No payments found

+

Try adjusting your filters to find what you're looking for.

+ {hasActiveFilters && ( + + )} + + ) : ( +
+
+
StatusAmountRecipientDateActions
-
- {payment.amount} - {payment.asset} -
-
-
- {payment.id.slice(0, 12)}... -

{payment.description || "No description"}

-
-
-

- {new Date(payment.created_at).toLocaleDateString(locale, { month: "short", day: "numeric", year: "numeric" })} -

-
-
- -
-
+ + + {["Status", "Amount", "Recipient", "Date", "Actions"].map((h, i) => ( + ))} - -
-
+ + + + {payments.map((payment) => ( + setHoveredPayment(payment.id)} + onMouseLeave={() => setHoveredPayment(null)} + onClick={() => handlePaymentClick(payment.id)} + className={`group cursor-pointer transition-all hover:bg-[#F9F9F9] ${flashedIds.has(payment.id) ? "bg-emerald-50" : ""}`} + > + + +
+ {payment.amount} + {payment.asset} +
+ + +
+ {payment.id.slice(0, 12)}… +

{payment.description || "No description"}

+
+ + +

+ {new Date(payment.created_at).toLocaleDateString(locale, { month: "short", day: "numeric", year: "numeric" })} +

+ + + + + + ))} + +
- )} +
+ )} - {/* Pagination Placeholder */} - {totalCount > LIMIT && ( -
-

End of list (Showing {LIMIT} most recent)

-
- )} -
+ {totalCount > LIMIT && ( +
+

End of list (Showing {LIMIT} most recent)

+
+ )}
- {/* Payment Detail Modal */} + {/* Modals */} {selectedPayment && ( - + { setIsModalOpen(false); setSelectedPayment(null); }} /> )} - - {/* Payment Detail Sheet */} {selectedPayment && ( - + { setIsSheetOpen(false); setSelectedPayment(null); }} /> )}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/TransactionFilterSidebar.test.tsx b/frontend/src/components/TransactionFilterSidebar.test.tsx index a89ff86..535b49c 100644 --- a/frontend/src/components/TransactionFilterSidebar.test.tsx +++ b/frontend/src/components/TransactionFilterSidebar.test.tsx @@ -1,158 +1,705 @@ /** @vitest-environment jsdom */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import TransactionFilterSidebar from "./TransactionFilterSidebar"; +/** + * Unit tests for + * + * Coverage + * ──────── + * ✅ Rendering — desktop panel, mobile drawer, all fields & options + * ✅ Controlled — every filter key reflected in the UI + * ✅ Interactions — every onChange / onClear handler fires correctly + * ✅ Pending states — searchSyncPending, isFilterPending, anyPending + * ✅ Accessibility — roles, labels, aria-busy, aria-pressed, + * aria-modal, aria-describedby, aria-live + * ✅ Mobile drawer — open/close via button and backdrop click + * ✅ Edge cases — disabled Clear All, hidden clear-search, "Clearing…" + * + * Component patches required (TransactionFilterSidebar.tsx): + * 1. Add aria-label="Clear search" to the clear-search motion.button + * 2. Add aria-hidden="true" to the SyncSpinner wrapper span inside asset buttons + * so the spinner text is excluded from the button's accessible name + */ + import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom/vitest"; -// Mock framer-motion to avoid animation issues in tests -vi.mock("framer-motion", () => ({ - motion: { - div: ({ children, ...props }: any) =>
{children}
, - aside: ({ children, ...props }: any) => , - }, - AnimatePresence: ({ children }: any) => <>{children}, -})); +import TransactionFilterSidebar from "./TransactionFilterSidebar"; + +// ─── framer-motion mock ─────────────────────────────────────────────────────── +// jsdom has no layout engine so framer-motion's measurement APIs fail. +// Every used export is replaced with a transparent pass-through that forwards +// ALL HTML/ARIA attributes (aria-pressed, aria-busy, aria-label, disabled…). -describe("TransactionFilterSidebar", () => { - const defaultFilters = { - search: "", - status: "all", - asset: "all", - dateFrom: "", - dateTo: "", +vi.mock("framer-motion", () => { + const strip = (props: Record) => { + const { initial: _i, animate: _a, exit: _e, transition: _t, whileTap: _w, ...rest } = props; + return rest; }; - const mockProps = { - filters: defaultFilters, - onFilterChange: vi.fn(), - onClearFilter: vi.fn(), - onClearAll: vi.fn(), - hasActiveFilters: false, - isOpen: true, - onClose: vi.fn(), + return { + motion: { + div: ({ children, ...p }: any) =>
{children}
, + aside: ({ children, ...p }: any) => , + span: ({ children, ...p }: any) => {children}, + // button MUST forward every prop including aria-* and disabled + button: ({ children, ...p }: any) => , + }, + AnimatePresence: ({ children }: any) => <>{children}, + }; +}); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const DEFAULT_FILTERS = { + search: "", + status: "all", + asset: "all", + dateFrom: "", + dateTo: "", +}; + +function buildProps( + overrides: Partial> = {}, +) { + return { + filters: DEFAULT_FILTERS, + onFilterChange: vi.fn(), + onClearFilter: vi.fn(), + onClearAll: vi.fn(), + hasActiveFilters: false, + isOpen: false, + onClose: vi.fn(), + searchSyncPending: false, + isFilterPending: false, + ...overrides, }; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Returns the always-present desktop sticky panel. */ +function getDesktopPanel(container: HTMLElement) { + return container.querySelector(".hidden.lg\\:block") as HTMLElement; +} + +/** + * getByLabelText matches ANY element whose accessible name includes the text, + * including role="status" spinners whose aria-label contains "Search", "From", + * or "To". Adding selector: 'input' or selector: 'select' pins the query to + * the actual form control so we never get a "Found multiple elements" error. + */ +function getInput(panel: HTMLElement, label: RegExp) { + return within(panel).getByLabelText(label, { selector: "input" }); +} +function getSelect(panel: HTMLElement, label: RegExp) { + return within(panel).getByLabelText(label, { selector: "select" }); +} + +// ============================================================================= +describe("TransactionFilterSidebar", () => { beforeEach(() => { vi.clearAllMocks(); }); - describe("Rendering", () => { - it("should render all filter sections", () => { - render(); + // ── 1. Rendering ──────────────────────────────────────────────────────── + + describe("1 · Rendering", () => { + describe("desktop panel", () => { + it("always renders the sticky desktop panel", () => { + const { container } = render(); + expect(getDesktopPanel(container)).toBeInTheDocument(); + }); + + it("renders the 'Filters' heading", () => { + const { container } = render(); + expect(within(getDesktopPanel(container)).getByText("Filters")).toBeInTheDocument(); + }); + + it("renders Search input, Status select, Asset group, From and To date inputs", () => { + const { container } = render(); + const panel = getDesktopPanel(container); + expect(getInput(panel, /Search/i)).toBeInTheDocument(); + expect(getSelect(panel, /Status/i)).toBeInTheDocument(); + expect(within(panel).getByRole("group", { name: /Asset filter/i })).toBeInTheDocument(); + expect(getInput(panel, /From/i)).toBeInTheDocument(); + expect(getInput(panel, /To/i)).toBeInTheDocument(); + }); + + it("renders all 5 status options with correct display labels", () => { + const { container } = render(); + const select = getSelect(getDesktopPanel(container), /Status/i) as HTMLSelectElement; + expect(Array.from(select.options).map((o) => o.text)).toEqual([ + "All Statuses", "Pending", "Confirmed", "Failed", "Refunded", + ]); + }); + + it("renders All / XLM / USDC asset buttons", () => { + const { container } = render(); + const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); + expect(within(group).getByRole("button", { name: /^All$/i })).toBeInTheDocument(); + expect(within(group).getByRole("button", { name: /^XLM$/i })).toBeInTheDocument(); + expect(within(group).getByRole("button", { name: /^USDC$/i })).toBeInTheDocument(); + }); + + it("renders the 'Clear All Filters' footer button", () => { + const { container } = render(); + expect( + within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), + ).toBeInTheDocument(); + }); + }); - expect(screen.getAllByText("Filters").length).toBeGreaterThan(0); - expect(screen.getAllByLabelText(/Search/i).length).toBeGreaterThan(0); - expect(screen.getAllByLabelText(/Status/i).length).toBeGreaterThan(0); - expect(screen.getAllByText(/Asset/i).length).toBeGreaterThan(0); - expect(screen.getAllByText(/Date Range/i).length).toBeGreaterThan(0); + describe("mobile drawer", () => { + it("does NOT render the dialog when isOpen=false", () => { + render(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders the dialog when isOpen=true", () => { + render(); + expect(screen.getByRole("dialog", { name: /Filter sidebar/i })).toBeInTheDocument(); + }); + + it("dialog has aria-modal='true'", () => { + render(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); + + it("dialog contains all the same filter fields as the desktop panel", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByLabelText(/Search/i, { selector: "input" })).toBeInTheDocument(); + expect(within(dialog).getByLabelText(/Status/i, { selector: "select" })).toBeInTheDocument(); + expect(within(dialog).getByRole("group", { name: /Asset filter/i })).toBeInTheDocument(); + expect(within(dialog).getByLabelText(/From/i, { selector: "input" })).toBeInTheDocument(); + expect(within(dialog).getByLabelText(/To/i, { selector: "input" })).toBeInTheDocument(); + }); }); - it("should display current filter values", () => { - const activeFilters = { - search: "test-id", - status: "confirmed", - asset: "USDC", - dateFrom: "2023-01-01", - dateTo: "2023-12-31", - }; + describe("controlled values", () => { + it("reflects search value in the search input", () => { + const { container } = render( + , + ); + expect(getInput(getDesktopPanel(container), /Search/i)).toHaveValue("abc-123"); + }); + + it("reflects status value in the status select", () => { + const { container } = render( + , + ); + expect(getSelect(getDesktopPanel(container), /Status/i)).toHaveValue("failed"); + }); + + it("reflects dateFrom value", () => { + const { container } = render( + , + ); + expect(getInput(getDesktopPanel(container), /From/i)).toHaveValue("2024-03-01"); + }); + + it("reflects dateTo value", () => { + const { container } = render( + , + ); + expect(getInput(getDesktopPanel(container), /To/i)).toHaveValue("2024-12-31"); + }); + + it("marks active asset button with aria-pressed='true'", () => { + const { container } = render( + , + ); + const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); + expect(within(group).getByRole("button", { name: /^USDC$/i })).toHaveAttribute("aria-pressed", "true"); + }); + + it("marks inactive asset buttons with aria-pressed='false'", () => { + const { container } = render( + , + ); + const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); + expect(within(group).getByRole("button", { name: /^XLM$/i })).toHaveAttribute("aria-pressed", "false"); + expect(within(group).getByRole("button", { name: /^All$/i })).toHaveAttribute("aria-pressed", "false"); + }); + }); + }); - render(); + // ── 2. Interactions ────────────────────────────────────────────────────── + + describe("2 · Interactions", () => { + describe("search input", () => { + it("calls onFilterChange('search', value) on change", async () => { + const props = buildProps(); + const { container } = render(); + await userEvent.type(getInput(getDesktopPanel(container), /Search/i), "x"); + expect(props.onFilterChange).toHaveBeenCalledWith("search", "x"); + }); + + it("does NOT render clear-search button when search is empty", () => { + const { container } = render(); + expect(within(getDesktopPanel(container)).queryByLabelText(/Clear search/i)).not.toBeInTheDocument(); + }); + + it("renders clear-search button when search has a value", () => { + const { container } = render( + , + ); + expect(within(getDesktopPanel(container)).getByLabelText(/Clear search/i)).toBeInTheDocument(); + }); + + it("calls onClearFilter('search') when clear-search button is clicked", () => { + const props = buildProps({ filters: { ...DEFAULT_FILTERS, search: "query" } }); + const { container } = render(); + fireEvent.click(within(getDesktopPanel(container)).getByLabelText(/Clear search/i)); + expect(props.onClearFilter).toHaveBeenCalledWith("search"); + }); + }); - const searchInputs = screen.getAllByLabelText(/Search/i); - expect(searchInputs[0]).toHaveValue("test-id"); + describe("status select", () => { + it.each(["pending", "confirmed", "failed", "refunded"] as const)( + "calls onFilterChange('status', '%s')", + (status) => { + const props = buildProps(); + const { container } = render(); + fireEvent.change(getSelect(getDesktopPanel(container), /Status/i), { + target: { value: status }, + }); + expect(props.onFilterChange).toHaveBeenCalledWith("status", status); + }, + ); + }); + + describe("asset buttons", () => { + it.each(["all", "XLM", "USDC"] as const)( + "calls onFilterChange('asset', '%s') on click", + (asset) => { + const props = buildProps(); + const { container } = render(); + const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); + const label = asset === "all" ? /^All$/i : new RegExp(`^${asset}$`, "i"); + fireEvent.click(within(group).getByRole("button", { name: label })); + expect(props.onFilterChange).toHaveBeenCalledWith("asset", asset); + }, + ); + }); - const statusSelects = screen.getAllByLabelText(/Status/i); - expect(statusSelects[0]).toHaveValue("confirmed"); + describe("date inputs", () => { + it("calls onFilterChange('dateFrom', value)", () => { + const props = buildProps(); + const { container } = render(); + fireEvent.change(getInput(getDesktopPanel(container), /From/i), { + target: { value: "2024-01-15" }, + }); + expect(props.onFilterChange).toHaveBeenCalledWith("dateFrom", "2024-01-15"); + }); + + it("calls onFilterChange('dateTo', value)", () => { + const props = buildProps(); + const { container } = render(); + fireEvent.change(getInput(getDesktopPanel(container), /To/i), { + target: { value: "2024-06-30" }, + }); + expect(props.onFilterChange).toHaveBeenCalledWith("dateTo", "2024-06-30"); + }); + }); - const usdcButtons = screen.getAllByRole("button", { name: /^USDC$/i }); - expect(usdcButtons[0]).toHaveClass("bg-[var(--pluto-500)]"); + describe("Clear All button", () => { + it("is disabled when hasActiveFilters=false", () => { + const { container } = render( + , + ); + expect( + within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), + ).toBeDisabled(); + }); + + it("is enabled when hasActiveFilters=true", () => { + const { container } = render( + , + ); + expect( + within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), + ).not.toBeDisabled(); + }); + + it("calls onClearAll when clicked while enabled", () => { + const props = buildProps({ hasActiveFilters: true }); + const { container } = render(); + fireEvent.click( + within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), + ); + expect(props.onClearAll).toHaveBeenCalledTimes(1); + }); + + it("does NOT call onClearAll when clicked while disabled", () => { + const props = buildProps({ hasActiveFilters: false }); + const { container } = render(); + fireEvent.click( + within(getDesktopPanel(container)).getByRole("button", { name: /Clear All Filters/i }), + ); + expect(props.onClearAll).not.toHaveBeenCalled(); + }); }); }); - describe("Interactions (optimistic)", () => { - it("should call onFilterChange immediately when search changes (no debounce)", () => { - render(); + // ── 3. Pending visual feedback ─────────────────────────────────────────── + + describe("3 · Pending visual feedback", () => { + describe("searchSyncPending", () => { + it("sets aria-busy='true' on search input", () => { + const { container } = render( + , + ); + expect(getInput(getDesktopPanel(container), /Search/i)).toHaveAttribute("aria-busy", "true"); + }); + + it("sets aria-busy='false' on search input when not pending", () => { + const { container } = render(); + expect(getInput(getDesktopPanel(container), /Search/i)).toHaveAttribute("aria-busy", "false"); + }); + + it("shows 'Applying to results…' hint with aria-live='polite'", () => { + const { container } = render( + , + ); + const hint = within(getDesktopPanel(container)).getByText(/Applying to results/i); + expect(hint).toBeInTheDocument(); + expect(hint).toHaveAttribute("aria-live", "polite"); + }); + + it("hides hint when not pending", () => { + const { container } = render(); + expect( + within(getDesktopPanel(container)).queryByText(/Applying to results/i), + ).not.toBeInTheDocument(); + }); + + it("links search input to hint via aria-describedby", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + const input = getInput(panel, /Search/i); + const hintId = input.getAttribute("aria-describedby"); + expect(hintId).toBeTruthy(); + // The hint

shares the same id suffix as the desktop input; query the + // full container because both desktop and mobile panels are in the DOM. + const hintEl = container.querySelector(`#${hintId}`); + expect(hintEl).toBeInTheDocument(); + expect(hintEl?.textContent).toMatch(/Applying to results/i); + }); + + it("applies dashed border to search input", () => { + const { container } = render( + , + ); + expect(getInput(getDesktopPanel(container), /Search/i).className).toContain("border-dashed"); + }); + + it("does NOT apply dashed border when not pending", () => { + const { container } = render(); + expect(getInput(getDesktopPanel(container), /Search/i).className).not.toContain("border-dashed"); + }); + }); - const searchInputs = screen.getAllByLabelText(/Search/i); - fireEvent.change(searchInputs[0], { target: { value: "new search" } }); + describe("isFilterPending", () => { + it("sets aria-busy='true' on status select", () => { + const { container } = render( + , + ); + expect(getSelect(getDesktopPanel(container), /Status/i)).toHaveAttribute("aria-busy", "true"); + }); + + it("applies dashed border to status select", () => { + const { container } = render( + , + ); + expect(getSelect(getDesktopPanel(container), /Status/i).className).toContain("border-dashed"); + }); + + it("sets aria-busy='true' on dateFrom and dateTo inputs", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + expect(getInput(panel, /From/i)).toHaveAttribute("aria-busy", "true"); + expect(getInput(panel, /To/i)).toHaveAttribute("aria-busy", "true"); + }); + + it("applies dashed border to dateFrom when set + pending", () => { + const { container } = render( + , + ); + expect(getInput(getDesktopPanel(container), /From/i).className).toContain("border-dashed"); + }); + + it("applies dashed border to dateTo when set + pending", () => { + const { container } = render( + , + ); + expect(getInput(getDesktopPanel(container), /To/i).className).toContain("border-dashed"); + }); + + it("applies opacity-70 to the active asset button when pending — queried by aria-pressed", () => { + // The XLM button's accessible name becomes "XLMSyncing…" when the SyncSpinner + // is rendered inside it without aria-hidden. Until the component patch is applied + // (aria-hidden="true" on the spinner wrapper), we locate the button via + // aria-pressed="true" instead of by name. + const { container } = render( + , + ); + const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); + const activeBtn = within(group).getByRole("button", { pressed: true }); + expect(activeBtn.className).toContain("opacity-70"); + }); + + it("does NOT apply opacity-70 to inactive asset buttons", () => { + const { container } = render( + , + ); + const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); + // USDC is inactive — its name is unambiguous regardless of patch status + expect(within(group).getByRole("button", { name: /^USDC$/i }).className).not.toContain("opacity-70"); + }); + + it("active asset button has aria-pressed='true'", () => { + const { container } = render( + , + ); + const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); + expect(within(group).getByRole("button", { name: /^USDC$/i })).toHaveAttribute("aria-pressed", "true"); + }); + + it("inactive asset buttons have aria-pressed='false'", () => { + const { container } = render( + , + ); + const group = within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }); + // XLM is inactive here — no spinner inside, name is unambiguous + expect(within(group).getByRole("button", { name: /^XLM$/i })).toHaveAttribute("aria-pressed", "false"); + expect(within(group).getByRole("button", { name: /^All$/i })).toHaveAttribute("aria-pressed", "false"); + }); + }); - expect(mockProps.onFilterChange).toHaveBeenCalledTimes(1); - expect(mockProps.onFilterChange).toHaveBeenCalledWith("search", "new search"); + describe("anyPending (searchSyncPending || isFilterPending)", () => { + it("shows 'Clearing…' on Clear All when searchSyncPending=true", () => { + const { container } = render( + , + ); + expect(within(getDesktopPanel(container)).getByText(/Clearing…/i)).toBeInTheDocument(); + }); + + it("shows 'Clearing…' on Clear All when isFilterPending=true", () => { + const { container } = render( + , + ); + expect(within(getDesktopPanel(container)).getByText(/Clearing…/i)).toBeInTheDocument(); + }); + + it("shows 'Clear All Filters' label when no pending flags are set", () => { + const { container } = render( + , + ); + const panel = getDesktopPanel(container); + expect(within(panel).queryByText(/Clearing…/i)).not.toBeInTheDocument(); + expect(within(panel).getByText(/Clear All Filters/i)).toBeInTheDocument(); + }); + + it("SyncSpinner renders with role='status' while pending", () => { + const { container } = render( + , + ); + expect(within(getDesktopPanel(container)).getAllByRole("status").length).toBeGreaterThan(0); + }); }); + }); - it("should call onFilterChange immediately when selecting status", () => { - render(); + // ── 4. Accessibility ───────────────────────────────────────────────────── - const statusSelects = screen.getAllByLabelText(/Status/i); - fireEvent.change(statusSelects[0], { target: { value: "failed" } }); + describe("4 · Accessibility", () => { + it("search input type is 'text'", () => { + const { container } = render(); + expect(getInput(getDesktopPanel(container), /Search/i)).toHaveAttribute("type", "text"); + }); - expect(mockProps.onFilterChange).toHaveBeenCalledWith("status", "failed"); + it("date inputs type is 'date'", () => { + const { container } = render(); + const panel = getDesktopPanel(container); + expect(getInput(panel, /From/i)).toHaveAttribute("type", "date"); + expect(getInput(panel, /To/i)).toHaveAttribute("type", "date"); }); - it("should call onFilterChange when clicking an asset button", () => { - render(); + it("search input has a descriptive placeholder", () => { + const { container } = render(); + expect( + within(getDesktopPanel(container)).getByPlaceholderText(/ID or description/i), + ).toBeInTheDocument(); + }); - const xlmButtons = screen.getAllByRole("button", { name: /^XLM$/i }); - fireEvent.click(xlmButtons[0]); + it("decorative SVGs carry aria-hidden='true'", () => { + const { container } = render(); + expect( + getDesktopPanel(container).querySelectorAll("svg[aria-hidden='true']").length, + ).toBeGreaterThan(0); + }); - expect(mockProps.onFilterChange).toHaveBeenCalledWith("asset", "XLM"); + it("asset button group has role='group' with accessible aria-label", () => { + const { container } = render(); + expect( + within(getDesktopPanel(container)).getByRole("group", { name: /Asset filter/i }), + ).toBeInTheDocument(); }); - it("should call onFilterChange when changing dates", () => { - render(); + it("Status label is linked to the select element", () => { + const { container } = render(); + expect(getSelect(getDesktopPanel(container), /Status/i).tagName).toBe("SELECT"); + }); - const fromInputs = screen.getAllByLabelText(/From/i, { selector: "input" }); - fireEvent.change(fromInputs[0], { target: { value: "2024-01-01" } }); - expect(mockProps.onFilterChange).toHaveBeenCalledWith("dateFrom", "2024-01-01"); + it("Search label is linked to a text input", () => { + const { container } = render(); + const el = getInput(getDesktopPanel(container), /Search/i); + expect(el.tagName).toBe("INPUT"); + expect(el).toHaveAttribute("type", "text"); }); }); - describe("Filter Management", () => { - it("should call onClearAll when clicking Clear All Filters button", () => { - render(); - - const clearAllButtons = screen.getAllByRole("button", { name: /Clear All Filters/i }); - fireEvent.click(clearAllButtons[0]); + // ── 5. Mobile drawer ───────────────────────────────────────────────────── - expect(mockProps.onClearAll).toHaveBeenCalled(); + describe("5 · Mobile drawer", () => { + it("renders Close filters button inside the dialog", () => { + render(); + expect( + within(screen.getByRole("dialog")).getByLabelText(/Close filters/i), + ).toBeInTheDocument(); }); - it("should disable Clear All button when no active filters", () => { - render(); + it("calls onClose when Close filters button is clicked", () => { + const props = buildProps({ isOpen: true }); + render(); + fireEvent.click(screen.getByLabelText(/Close filters/i)); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); - const clearAllButtons = screen.getAllByRole("button", { name: /Clear All Filters/i }); - expect(clearAllButtons[0]).toBeDisabled(); + it("calls onClose when the backdrop overlay is clicked", () => { + const props = buildProps({ isOpen: true }); + const { container } = render(); + const backdrop = container.querySelector(".fixed.inset-0[aria-hidden='true']"); + expect(backdrop).toBeInTheDocument(); + fireEvent.click(backdrop!); + expect(props.onClose).toHaveBeenCalledTimes(1); }); - }); - describe("Accessibility & UX", () => { - it("should have proper ARIA labels", () => { - render(); + it("mobile search input calls onFilterChange on change", () => { + const props = buildProps({ isOpen: true }); + render(); + fireEvent.change( + within(screen.getByRole("dialog")).getByLabelText(/Search/i, { selector: "input" }), + { target: { value: "mobile-query" } }, + ); + expect(props.onFilterChange).toHaveBeenCalledWith("search", "mobile-query"); + }); - expect(screen.getByRole("dialog", { name: /Filter sidebar/i })).toBeInTheDocument(); + it("mobile Clear All button calls onClearAll", () => { + const props = buildProps({ isOpen: true, hasActiveFilters: true }); + render(); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: /Clear All Filters/i }), + ); + expect(props.onClearAll).toHaveBeenCalled(); }); + }); - it("should call onClose when close button is clicked", () => { - render(); + // ── 6. Edge cases ──────────────────────────────────────────────────────── - const closeButtons = screen.getAllByLabelText(/Close filters/i); - fireEvent.click(closeButtons[0]); + describe("6 · Edge cases", () => { + it("renders without crashing when onClose is undefined (desktop-only usage)", () => { + expect(() => + render(), + ).not.toThrow(); + }); - expect(mockProps.onClose).toHaveBeenCalled(); + it("renders without crashing with every flag and filter active simultaneously", () => { + expect(() => + render( + , + ), + ).not.toThrow(); }); - it("should set aria-busy on search while URL sync is pending", () => { - render( + it("'Clearing…' button stays disabled even while pending when hasActiveFilters=false", () => { + const { container } = render( , ); - - const searchInputs = screen.getAllByLabelText(/Search/i); - expect(searchInputs[0]).toHaveAttribute("aria-busy", "true"); + const btn = within(getDesktopPanel(container)).getByText(/Clearing…/i).closest("button"); + expect(btn).toBeDisabled(); }); }); -}); +}); \ No newline at end of file diff --git a/frontend/src/components/TransactionFilterSidebar.tsx b/frontend/src/components/TransactionFilterSidebar.tsx index f6ad5e9..0062eaf 100644 --- a/frontend/src/components/TransactionFilterSidebar.tsx +++ b/frontend/src/components/TransactionFilterSidebar.tsx @@ -1,8 +1,10 @@ "use client"; -import React from "react"; +import React, { useId } from "react"; import { motion, AnimatePresence } from "framer-motion"; +// ─── Types ────────────────────────────────────────────────────────────────── + interface FilterState { search: string; status: string; @@ -12,19 +14,29 @@ interface FilterState { } interface TransactionFilterSidebarProps { - /** Draft / optimistic filter state; should update synchronously on interaction. */ + /** Optimistic filter state; updates synchronously on every interaction. */ filters: FilterState; onFilterChange: (key: keyof FilterState, value: string) => void; onClearFilter: (key: keyof FilterState) => void; onClearAll: () => void; - /** Reflects draft filters so actions like Clear All stay usable before URL sync (e.g. search debounce). */ + /** Reflects draft filters — keeps actions like Clear All responsive before URL sync. */ hasActiveFilters: boolean; - /** When true, draft search differs from URL-applied search; URL update is in flight. */ + /** + * When true the draft search value is ahead of the committed URL value; + * a debounced flush is in flight. + */ searchSyncPending?: boolean; + /** + * When true a non-search filter is being committed to the URL inside a + * React transition. Use to show a subtle loading state on the results area. + */ + isFilterPending?: boolean; isOpen?: boolean; onClose?: () => void; } +// ─── Constants ─────────────────────────────────────────────────────────────── + const STATUS_OPTIONS = [ "all", "pending", @@ -35,6 +47,97 @@ const STATUS_OPTIONS = [ const ASSET_OPTIONS = ["all", "XLM", "USDC"] as const; +// ─── Small reusable pieces ─────────────────────────────────────────────────── + +/** Spinning ring shown while a filter is syncing to the URL. */ +function SyncSpinner({ label = "Syncing…" }: { label?: string }) { + return ( + + {/* Accessible spinner */} + + {label} + + ); +} + +/** Animated dot badge shown in the header when any filter is pending. */ +function PendingBadge({ visible }: { visible: boolean }) { + return ( + + {visible && ( + + )} + + ); +} + +/** + * Thin animated progress bar shown at the top of the sidebar while any filter + * change is in flight (either debounced search or transition-wrapped filter). + */ +function SyncProgressBar({ visible }: { visible: boolean }) { + return ( + + {visible && ( + + ); +} + +// ─── Main component ────────────────────────────────────────────────────────── + export default function TransactionFilterSidebar({ filters, onFilterChange, @@ -42,137 +145,371 @@ export default function TransactionFilterSidebar({ onClearAll, hasActiveFilters, searchSyncPending = false, + isFilterPending = false, isOpen = false, onClose, }: TransactionFilterSidebarProps) { + // Stable IDs for desktop vs mobile duplicate inputs (avoids duplicate-id a11y violations) + const uid = useId(); + const anyPending = searchSyncPending || isFilterPending; + const renderContent = (isMobile: boolean) => { - const idSuffix = isMobile ? "-mobile" : ""; + const suffix = isMobile ? `-${uid}-mobile` : `-${uid}-desktop`; + return ( -

+
+ {/* ── Sync progress bar ── */} + + + {/* ── Header ── */}
-

Filters

+
+

Filters

+ +
{onClose && isMobile && ( )}
-
- {/* Search: optimistic — parent owns draft + debounced URL sync */} + {/* ── Filter fields ── */} +
+ + {/* Search */}
- +
+ + + {searchSyncPending && ( + + + + )} + +
+
onFilterChange("search", e.target.value)} aria-busy={searchSyncPending} - placeholder="ID or description..." - className={`w-full rounded-xl border bg-[#F9F9F9] py-2.5 pl-10 pr-4 text-sm text-[#0A0A0A] placeholder:text-[#6B6B6B] focus:bg-white focus:outline-none transition-all ${ + aria-describedby={ + searchSyncPending + ? `sidebar-search-hint${suffix}` + : undefined + } + placeholder="ID or description…" + className={[ + "w-full rounded-xl border bg-[#F9F9F9] py-2.5 pl-10 pr-9 text-sm text-[#0A0A0A]", + "placeholder:text-[#6B6B6B] focus:bg-white focus:outline-none transition-all duration-200", searchSyncPending ? "border-dashed border-[var(--pluto-400)] focus:border-[var(--pluto-500)]" - : "border-[#E8E8E8] focus:border-[var(--pluto-500)]" - }`} + : "border-[#E8E8E8] focus:border-[var(--pluto-500)]", + ].join(" ")} /> - - + + {/* Search icon */} + + + {/* Clear search button — shown when there's a value */} + + {filters.search && ( + onClearFilter("search")} + aria-label="Clear search" + className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-[#A0A0A0] hover:bg-[#F0F0F0] hover:text-[#0A0A0A] transition-colors" + > + + + )} +
+ + {/* Accessible hint when debounce is pending */} + {searchSyncPending && ( +

+ Applying to results… +

+ )}
{/* Status */}
- - +
+ + + {isFilterPending && filters.status !== "all" && ( + + )} + +
+ +
+ + + {/* Chevron icon */} + +
{/* Asset */}
-
); @@ -180,15 +517,16 @@ export default function TransactionFilterSidebar({ return ( <> - {/* Desktop view: persistent on the right if needed, or floating */} + {/* Desktop: persistent sticky panel */}
{renderContent(false)}
- {/* Mobile view: Drawer */} + {/* Mobile: animated drawer */} {isOpen && ( <> + {/* Backdrop */}