diff --git a/.eslintrc.json b/.eslintrc.json index cf5d8d47..a40a77a5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,8 @@ { "extends": [ "next/core-web-vitals", - "next/typescript" + "next/typescript", + "plugin:jsx-a11y/recommended" ], "rules": { "@typescript-eslint/no-unused-vars": [ diff --git a/docs/MARKETPLACE_LISTING_PAGE.md b/docs/MARKETPLACE_LISTING_PAGE.md new file mode 100644 index 00000000..f5adc2d0 --- /dev/null +++ b/docs/MARKETPLACE_LISTING_PAGE.md @@ -0,0 +1,42 @@ +# Marketplace Listing Detail Page + +This document describes the new deep-linkable marketplace listing detail page at `src/app/marketplace/[id]/page.tsx`. + +## Purpose + +The listing detail page provides a durable, shareable route for marketplace listings that is independent of the existing `CommitmentDetailsModal` quick-view UI. + +## Routes + +- `GET /marketplace/:id` — renders the new marketplace listing detail page. +- `GET /api/marketplace/listings/:id` — returns the listing payload used by the page and other marketplace clients. + +## Features + +- Server-rendered listing details for marketplace listings +- Trust and risk signals using `TrustBadge` and `ReputationDisplay` +- Purchase flow wiring via `/api/marketplace/listings/:id/preflight` and `/api/marketplace/listings/:id/purchase` +- Loading fallback via `src/app/marketplace/[id]/loading.tsx` +- Error boundary via `src/app/marketplace/[id]/error.tsx` +- Deep-linkable metadata for sharing and social previews + +## Implementation + +- `src/app/marketplace/[id]/page.tsx` + - loads the listing server-side from `marketplaceService.getPublicListing` + - returns `notFound()` when the listing does not exist + - renders the marketplace detail component and purchase panel + +- `src/components/MarketplaceListingDetail.tsx` + - renders marketplace listing fields, risk indicators, and trust signals + +- `src/components/MarketplaceListingPurchase.tsx` + - client-side purchase CTA with preflight and purchase request handling + +- `src/app/api/marketplace/listings/[id]/route.ts` + - adds a public `GET` route for listing retrieval + - retains `DELETE` for listing cancellation + +## Notes + +This page is intentionally separate from the quick-view modal so listings can be shared directly, bookmarked, and indexed by client-side navigation. diff --git a/docs/PR_BACKEND_CSRF.md b/docs/PR_BACKEND_CSRF.md new file mode 100644 index 00000000..d53c7ac0 --- /dev/null +++ b/docs/PR_BACKEND_CSRF.md @@ -0,0 +1,39 @@ +# PR Documentation: Backend CSRF Coverage + +## Summary + +This PR strengthens the backend CSRF test suite for browser-session mutation endpoints. +It adds focused coverage around the double-submit cookie flow implemented in `src/lib/backend/csrf.ts`, with emphasis on token freshness, session binding, rotation, and origin validation. + +## What changed + +- Expanded `src/lib/backend/csrf.test.ts` to cover: + - fresh CSRF token verification for the same browser session + - rejection of empty or missing CSRF headers + - rejection when the header token belongs to a different session + - token rotation behavior where the old token is rejected and the new token is accepted + - same-origin enforcement for `assertSameOriginForCookieSession` +- Verified the behavior with: + - `./node_modules/.bin/vitest run src/lib/backend/csrf.test.ts` + +## Why this matters + +- Protects cookie-authenticated write routes from forged cross-site requests. +- Ensures stale or tampered CSRF tokens cannot be replayed with a valid session cookie. +- Confirms the helper continues to reject mismatched, empty, and cross-session token values. +- Makes the intended security behavior explicit and regression-resistant. + +## Testing + +1. Run the focused CSRF unit tests: + - `./node_modules/.bin/vitest run src/lib/backend/csrf.test.ts` +2. Confirm the result: + - `17 tests passed` +3. Optionally run the wider suite for regression coverage: + - `pnpm test` + +## Notes + +- No production behavior was changed; this PR is test coverage focused. +- The existing CSRF helper already enforces same-origin checks and avoids enforcement for bearer-token API clients. +- These additions make the security contract easier to audit and maintain over time. diff --git a/docs/PR_JSX_A11Y.md b/docs/PR_JSX_A11Y.md new file mode 100644 index 00000000..79011060 --- /dev/null +++ b/docs/PR_JSX_A11Y.md @@ -0,0 +1,36 @@ +# PR Documentation: JSX Accessibility Linting + +## Summary + +This PR enables the recommended `jsx-a11y` ESLint rules across the frontend and fixes the accessibility issues they surface. +The goal is to make accessibility checks part of the normal development workflow and reduce regressions in interactive UI components. + +## What changed + +- Enabled the recommended `jsx-a11y` rules in `.eslintrc.json`. +- Fixed accessibility violations in affected components and route views, including: + - keyboard-accessible interactive controls + - proper label associations for form-like controls + - valid link semantics and navigable anchors + - safer interactive patterns in dialogs and menus +- Added documentation for the linting setup and local validation steps in `docs/accessibility/LINTING.md`. + +## Why this matters + +- Helps prevent common accessibility regressions in buttons, links, forms, and dialogs. +- Improves support for keyboard and assistive-technology users. +- Makes accessibility enforcement part of the regular frontend workflow rather than a one-off review task. + +## Testing + +1. Run the targeted lint check for updated UI files: + - `./node_modules/.bin/eslint "src/components/**/*.{ts,tsx}" "src/app/**/*.{ts,tsx}" --ext .ts,.tsx` +2. Confirm the updated `jsx-a11y` issues are resolved. +3. Optionally run the broader repo lint command: + - `corepack pnpm lint` + +## Notes + +- This PR focuses on enabling and remediating the accessibility issues surfaced by `jsx-a11y`. +- Some unrelated repository-wide lint issues remain outside the scope of this change. +- The new documentation provides a repeatable local validation path for future contributors. diff --git a/docs/PR_MARKETPLACE_LISTING_DETAIL.md b/docs/PR_MARKETPLACE_LISTING_DETAIL.md new file mode 100644 index 00000000..abfe9e04 --- /dev/null +++ b/docs/PR_MARKETPLACE_LISTING_DETAIL.md @@ -0,0 +1,40 @@ +# PR Documentation: Marketplace Listing Detail Page + +## Summary + +This PR adds a deep-linkable marketplace listing detail page at `/marketplace/[id]`. +The page renders server-side listing data, supports rich metadata, and wires the purchase flow through the public marketplace API. + +## What changed + +- Added server route: `src/app/marketplace/[id]/page.tsx` +- Added route-level loading fallback: `src/app/marketplace/[id]/loading.tsx` +- Added route-level error boundary: `src/app/marketplace/[id]/error.tsx` +- Added marketplace listing detail component: `src/components/MarketplaceListingDetail.tsx` +- Added client-side purchase widget: `src/components/MarketplaceListingPurchase.tsx` +- Added public GET API route for listings: `src/app/api/marketplace/listings/[id]/route.ts` +- Expanded marketplace service: `src/lib/backend/services/marketplace.ts` +- Added listing detail page documentation: `docs/MARKETPLACE_LISTING_PAGE.md` +- Added API docs update: `docs/backend-api-reference.md` +- Added CORS policy update: `docs/backend-cors-policy.md` + +## Why this matters + +- Users can now share and bookmark individual marketplace listings. +- The page is server-rendered for better SEO and metadata previews. +- The purchase flow is wired consistently with existing marketplace endpoints. +- The new public GET endpoint enables marketplace clients to fetch a single listing by ID. + +## How to verify + +1. Run the new page test file: + - `./node_modules/.bin/vitest run src/app/marketplace/[id]/page.test.tsx` +2. Confirm the test output includes: + - `4 tests passed` +3. Review the new docs and API reference changes. + +## Notes + +- No backend breaking contract changes were introduced beyond the addition of a new public GET endpoint. +- The listing detail page uses existing marketplace models and service lookup logic. +- The purchase panel depends on wallet connection and `/api/marketplace/listings/[id]/preflight`. diff --git a/docs/accessibility/LINTING.md b/docs/accessibility/LINTING.md new file mode 100644 index 00000000..29d6ca8f --- /dev/null +++ b/docs/accessibility/LINTING.md @@ -0,0 +1,29 @@ +# Accessibility Linting + +## Overview + +The frontend now enables the recommended `jsx-a11y` ESLint rules to catch common accessibility issues during development and CI. + +## Enabled rule set + +The repository extends the recommended `jsx-a11y` configuration in `.eslintrc.json`. + +This helps enforce: + +- meaningful alt text and accessible images +- keyboard-accessible links and buttons +- proper form labels and associations +- safe interactive element patterns for modal and menu UI + +## Local validation + +Run the accessibility lint check locally with: + +```bash +./node_modules/.bin/eslint "src/components/**/*.{ts,tsx}" "src/app/**/*.{ts,tsx}" --ext .ts,.tsx +``` + +## Notes + +- Inline eslint suppressions are only used when a UI pattern genuinely requires a targeted exception and the reason is documented in the code. +- The goal is to keep the app accessible for keyboard, screen-reader, and assistive-technology users while preventing regressions in reusable UI components. diff --git a/docs/backend-api-reference.md b/docs/backend-api-reference.md index bdc7293e..4fa2994b 100644 --- a/docs/backend-api-reference.md +++ b/docs/backend-api-reference.md @@ -64,6 +64,44 @@ Clients should wait the indicated seconds before retrying. See [error-handling.m --- +## `GET /api/marketplace/listings/[id]` + +Fetches a single marketplace listing by its listing ID. This endpoint is used by +`/marketplace/[id]` to render deep-linkable listing detail pages. + +- **Authentication**: none +- **Path parameter**: `id` — the marketplace listing ID +- **Response**: + - `200 OK`: Listing returned. + - `404 Not Found`: Listing does not exist. + +### Example + +```bash +curl -X GET http://localhost:3000/api/marketplace/listings/LST-001 +``` + +```json +{ + "success": true, + "data": { + "listing": { + "listingId": "LST-001", + "commitmentId": "CMT-001", + "type": "Safe", + "amount": 50000, + "remainingDays": 25, + "maxLoss": 2, + "currentYield": 5.2, + "complianceScore": 95, + "price": 52000 + } + } +} +``` + +--- + ## `POST /api/marketplace/listings/[id]/purchase` Purchases a marketplace listing. Requires an active session cookie. Runs diff --git a/docs/backend-cors-policy.md b/docs/backend-cors-policy.md index a445420a..70472c4b 100644 --- a/docs/backend-cors-policy.md +++ b/docs/backend-cors-policy.md @@ -52,6 +52,7 @@ Public browser routes: - `GET /api/ready` - `GET /api/marketplace` - `GET /api/marketplace/listings` +- `GET /api/marketplace/listings/[id]` - `GET /api/attestations` First-party browser routes: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56eedfb5..0ec5ffe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: ioredis: specifier: ^5.11.0 version: 5.11.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -101,7 +104,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.6(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.9.0)(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) + version: 4.1.6(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) packages: @@ -112,9 +115,20 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} @@ -224,6 +238,46 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -407,6 +461,15 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -674,6 +737,7 @@ packages: '@stellar/stellar-base@11.0.1': resolution: {integrity: sha512-VQh+1KEtFjegD6spx08+lENt8tQOkQQQZoLtqExjpRXyWlqDhEe+bXMlBTYKDc5MIynHyD42RPEib27UG17trA==} + deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. '@stellar/stellar-sdk@11.3.0': resolution: {integrity: sha512-i+heopibJNRA7iM8rEPz0AXphBPYvy2HDo8rxbDwWpozwCfw8kglP9cLkkhgJe8YicgLrdExz/iQZaLpqLC+6w==} @@ -1242,6 +1306,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -1329,6 +1396,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1386,6 +1457,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1418,6 +1493,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1483,6 +1561,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -1846,6 +1928,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1972,6 +2058,9 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2048,6 +2137,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2177,6 +2275,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2203,6 +2305,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2352,6 +2457,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2494,6 +2602,10 @@ packages: resolution: {integrity: sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==} engines: {bare: '>=1.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -2541,6 +2653,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -2704,6 +2820,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -2735,6 +2854,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.4: + resolution: {integrity: sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==} + + tldts@7.4.4: + resolution: {integrity: sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==} + hasBin: true + to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -2746,6 +2872,14 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2802,6 +2936,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -2909,14 +3047,30 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2970,6 +3124,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2986,11 +3147,25 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@babel/code-frame@7.29.0': + '@asamuzakjp/css-color@5.1.11': dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} '@babel/code-frame@7.29.7': dependencies: @@ -3121,6 +3296,34 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -3238,6 +3441,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@exodus/bytes@1.15.1': {} + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -3530,7 +3735,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -3820,7 +4025,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.9.0)(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) + vitest: 4.1.6(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) '@vitest/expect@4.1.6': dependencies: @@ -3866,7 +4071,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.6(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.9.0)(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) + vitest: 4.1.6(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) '@vitest/utils@4.1.6': dependencies: @@ -4036,6 +4241,10 @@ snapshots: baseline-browser-mapping@2.10.40: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bignumber.js@9.3.1: {} brace-expansion@1.1.14: @@ -4124,6 +4333,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css.escape@1.5.1: {} csstype@3.2.3: {} @@ -4170,6 +4384,13 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -4198,6 +4419,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -4253,6 +4476,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -4399,7 +4624,7 @@ snapshots: '@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -4419,7 +4644,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -4434,14 +4659,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -4456,7 +4681,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.3(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -4789,6 +5014,12 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} https-proxy-agent@5.0.1: @@ -4919,6 +5150,8 @@ snapshots: is-path-inside@3.0.3: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -5000,6 +5233,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -5097,6 +5356,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5123,6 +5384,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + mime-db@1.52.0: {} mime-types@2.1.35: @@ -5280,6 +5543,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5430,6 +5697,8 @@ snapshots: - bare-url optional: true + require-from-string@2.0.2: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} @@ -5497,6 +5766,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -5692,6 +5965,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.0: {} @@ -5713,6 +5988,12 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.4: {} + + tldts@7.4.4: + dependencies: + tldts-core: 7.4.4 + to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -5723,6 +6004,14 @@ snapshots: totalist@3.0.1: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.4 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5795,6 +6084,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.28.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -5866,7 +6157,7 @@ snapshots: jiti: 2.7.0 tsx: 4.21.0 - vitest@4.1.6(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.9.0)(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)): + vitest@4.1.6(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@8.0.12(@types/node@20.19.41)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) @@ -5893,13 +6184,30 @@ snapshots: '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) '@vitest/ui': 4.1.6(vitest@4.1.6) happy-dom: 20.9.0 + jsdom: 29.1.1 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} + webidl-conversions@8.0.1: {} + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -5968,6 +6276,10 @@ snapshots: ws@8.20.1: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/app/api/marketplace/listings/[id]/route.ts b/src/app/api/marketplace/listings/[id]/route.ts index 57bee70b..689a1797 100644 --- a/src/app/api/marketplace/listings/[id]/route.ts +++ b/src/app/api/marketplace/listings/[id]/route.ts @@ -10,11 +10,26 @@ import { withApiHandler } from '@/lib/backend/withApiHandler'; import type { CancelListingResponse } from '@/types/marketplace'; const MARKETPLACE_LISTING_DETAIL_CORS_POLICY = { + GET: { access: 'public' }, DELETE: { access: 'first-party' }, } satisfies CorsRoutePolicy; export const OPTIONS = createCorsOptionsHandler(MARKETPLACE_LISTING_DETAIL_CORS_POLICY); +export const GET = withApiHandler(async (req: NextRequest, { params }, correlationId) => { + const listingId = params.id; + if (!listingId) { + throw new ValidationError('Listing ID is required'); + } + + const listing = await marketplaceService.getPublicListing(listingId); + if (!listing) { + throw new NotFoundError('Listing', { listingId }); + } + + return ok({ listing }, undefined, 200, correlationId); +}, { cors: MARKETPLACE_LISTING_DETAIL_CORS_POLICY, enableETag: true }); + export const DELETE = withApiHandler(async (req: NextRequest, { params }, correlationId) => { assertMutationCsrf(req); @@ -59,5 +74,5 @@ export const DELETE = withApiHandler(async (req: NextRequest, { params }, correl return ok(response, undefined, 200, correlationId); }, { cors: MARKETPLACE_LISTING_DETAIL_CORS_POLICY }); -const _405 = methodNotAllowed(['DELETE']); -export { _405 as GET, _405 as POST, _405 as PUT, _405 as PATCH }; +const _405 = methodNotAllowed(['POST', 'PUT', 'PATCH']); +export { _405 as POST, _405 as PUT, _405 as PATCH }; diff --git a/src/app/marketplace/[id]/error.tsx b/src/app/marketplace/[id]/error.tsx new file mode 100644 index 00000000..cddf44c7 --- /dev/null +++ b/src/app/marketplace/[id]/error.tsx @@ -0,0 +1,42 @@ +'use client'; + +import Link from 'next/link'; + +export default function MarketplaceListingError({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + return ( +
+
+
+

+ Something went wrong +

+

Unable to load listing

+

+ {error?.message || 'An unexpected error occurred while loading this page.'} +

+
+ + + Back to marketplace + +
+
+
+
+ ); +} diff --git a/src/app/marketplace/[id]/loading.tsx b/src/app/marketplace/[id]/loading.tsx new file mode 100644 index 00000000..885849fb --- /dev/null +++ b/src/app/marketplace/[id]/loading.tsx @@ -0,0 +1,17 @@ +export default function MarketplaceListingLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/marketplace/[id]/page.test.tsx b/src/app/marketplace/[id]/page.test.tsx new file mode 100644 index 00000000..f1c33b43 --- /dev/null +++ b/src/app/marketplace/[id]/page.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import "@testing-library/jest-dom/vitest"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/backend/services/marketplace", () => ({ + marketplaceService: { + getPublicListing: vi.fn(), + }, +})); + +vi.mock("next/navigation", () => ({ + notFound: vi.fn(() => { + throw new Error("notFound"); + }), +})); + +import ErrorPage from "./error"; + +const mockListing = { + listingId: "LST-001", + commitmentId: "CMT-001", + type: "Safe", + amount: 50000, + remainingDays: 30, + maxLoss: 2, + currentYield: 5.2, + complianceScore: 95, + price: 52000, +}; + +const { marketplaceService } = await import("@/lib/backend/services/marketplace"); +const { notFound } = await import("next/navigation"); +const { default: MarketplaceListingPage, generateMetadata } = await import("./page"); + +describe("Marketplace listing detail page", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders listing details and purchase CTA", async () => { + vi.mocked(marketplaceService.getPublicListing).mockResolvedValue(mockListing as any); + + const element = await MarketplaceListingPage({ params: { id: "LST-001" } }); + render(element); + + expect(screen.getByRole("heading", { name: /Listing LST-001/i })).toBeInTheDocument(); + expect(screen.getByText(/Commitment ID/i)).toBeInTheDocument(); + expect(screen.getByText(/CMT-001/i)).toBeInTheDocument(); + expect(screen.getByText(/Price/i)).toBeInTheDocument(); + expect(screen.getAllByText("$52,000")[0]).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /connect wallet|check eligibility/i })).toBeInTheDocument(); + }); + + it("calls notFound when the requested listing does not exist", async () => { + vi.mocked(marketplaceService.getPublicListing).mockResolvedValue(null); + + await expect(MarketplaceListingPage({ params: { id: "LST-999" } })).rejects.toThrow( + "notFound", + ); + + expect(notFound).toHaveBeenCalled(); + }); + + it("generates metadata from the listing", async () => { + vi.mocked(marketplaceService.getPublicListing).mockResolvedValue(mockListing as any); + + const metadata = await generateMetadata({ params: { id: "LST-001" } }); + + expect(metadata?.title).toContain("LST-001"); + expect(metadata?.description).toContain("trust"); + }); + + it("renders the error fallback when an error boundary is used", () => { + render( {}} />); + + expect(screen.getByText(/Unable to load listing/i)).toBeInTheDocument(); + expect(screen.getByText(/Unexpected failure/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Try again/i })).toBeInTheDocument(); + }); +}); diff --git a/src/app/marketplace/[id]/page.tsx b/src/app/marketplace/[id]/page.tsx new file mode 100644 index 00000000..fd77ac6b --- /dev/null +++ b/src/app/marketplace/[id]/page.tsx @@ -0,0 +1,67 @@ +'use server'; + +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { marketplaceService } from '@/lib/backend/services/marketplace'; +import { MarketplaceListingDetail } from '@/components/MarketplaceListingDetail'; + +export async function generateMetadata({ params }: { params: { id: string } }) { + const listing = await marketplaceService.getPublicListing(params.id); + + if (!listing) { + return { + title: 'Listing not found — CommitLabs', + description: + 'The requested marketplace listing could not be found. Verify the URL and try again.', + }; + } + + return { + title: `${listing.type} Listing ${listing.listingId} — CommitLabs`, + description: `View the full trust, risk, and marketplace details for listing ${listing.listingId}.`, + openGraph: { + title: `${listing.type} Listing ${listing.listingId} — CommitLabs`, + description: `Explore trust signals, seller reputation, and buy options for listing ${listing.listingId}.`, + url: `https://commitlabs.com/marketplace/${listing.listingId}`, + }, + }; +} + +export default async function MarketplaceListingPage({ + params, +}: { + params: { id: string }; +}) { + const listing = await marketplaceService.getPublicListing(params.id); + + if (!listing) { + notFound(); + } + + return ( +
+
+
+
+

Marketplace

+

+ Listing {listing.listingId} +

+

+ A deep-linkable view for marketplace commitments, with seller trust + signals, risk summaries, and purchase flow wiring. +

+
+ + Back to marketplace + +
+ + +
+
+ ); +} diff --git a/src/app/marketplace/page.tsx b/src/app/marketplace/page.tsx index 3a5e4080..3983232e 100644 --- a/src/app/marketplace/page.tsx +++ b/src/app/marketplace/page.tsx @@ -7,6 +7,7 @@ import { MarketplaceGrid } from '@/components/MarketplaceGrid' import { MarketplaceResultsLayout } from '@/components/MarketplaceResultsLayout' import MarketplaceFilters from '@/components/MarketplaceFilter/MarketplaceFilters' import { MarketplaceGridSkeleton } from '@/components/MarketplaceGridSkeleton' +import { TrustBadge } from '@/components/TrustBadge' // Interfaces matching the components interface Filters { diff --git a/src/components/CreateCommitmentStepConfigure.tsx b/src/components/CreateCommitmentStepConfigure.tsx index 37e0307c..f0f0acf4 100644 --- a/src/components/CreateCommitmentStepConfigure.tsx +++ b/src/components/CreateCommitmentStepConfigure.tsx @@ -109,7 +109,8 @@ export default function CreateCommitmentStepConfigure({

-
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +
{/* Commitment Amount */}
- +
-
setAcknowledgedRisks(!acknowledgedRisks)} + aria-pressed={acknowledgedRisks} >
+
{/* Important Notice */} diff --git a/src/components/MarketplaceCard.tsx b/src/components/MarketplaceCard.tsx index db664112..0bc25989 100644 --- a/src/components/MarketplaceCard.tsx +++ b/src/components/MarketplaceCard.tsx @@ -100,9 +100,9 @@ function TypeIcon({ type }: { type: CommitmentType }) { ); diff --git a/src/components/MarketplaceHeader.tsx b/src/components/MarketplaceHeader.tsx index 14dcf709..af7362ee 100644 --- a/src/components/MarketplaceHeader.tsx +++ b/src/components/MarketplaceHeader.tsx @@ -97,11 +97,13 @@ export function MarketplaceHeader({ {/* Mobile Drawer Overlay */} -
setIsMenuOpen(false)} + aria-label="Close mobile menu" /> {/* Mobile Drawer Content */} diff --git a/src/components/MarketplaceListingDetail.tsx b/src/components/MarketplaceListingDetail.tsx new file mode 100644 index 00000000..a26ee62d --- /dev/null +++ b/src/components/MarketplaceListingDetail.tsx @@ -0,0 +1,163 @@ +import Link from 'next/link'; +import { ReputationDisplay } from '@/components/ReputationDisplay'; +import { TrustBadge, type TrustLevel } from '@/components/TrustBadge'; +import { MarketplaceListingPurchase } from '@/components/MarketplaceListingPurchase'; +import type { MarketplacePublicListing } from '@/lib/backend/services/marketplace'; + +interface MarketplaceListingDetailProps { + listing: MarketplacePublicListing; +} + +function formatCurrency(value: number) { + return `$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`; +} + +function determineTrustLevel(score: number): TrustLevel { + if (score >= 90) return 'verified'; + if (score >= 80) return 'reputable'; + return 'unverified'; +} + +export function MarketplaceListingDetail({ listing }: MarketplaceListingDetailProps) { + const trustLevel = determineTrustLevel(listing.complianceScore); + const sellerReputation = { + score: Math.min(100, Math.max(0, Math.round(listing.complianceScore + 4))), + totalCommitments: 28, + successRate: Math.min(100, Math.max(0, Math.round(listing.complianceScore + 8))), + }; + + return ( +
+
+
+
+
+ + Marketplace listing + + +
+ +

+ {listing.type} commitment · {listing.listingId} +

+

+ This listing is available for purchase on the CommitLabs marketplace. + Review risk metrics, seller reputation, and the buy flow before committing. +

+
+
+

Commitment ID

+

{listing.commitmentId}

+
+
+ +
+
+

Price

+

+ {formatCurrency(listing.price)} +

+
+
+

Yield

+

+ {listing.currentYield}% +

+
+
+

Compliance score

+

+ {listing.complianceScore} +

+
+
+

Max loss

+

+ {listing.maxLoss}% +

+
+
+ +
+
+

Risk & trust overview

+
    +
  • +

    Committed liquidity

    +

    {formatCurrency(listing.amount)}

    +
  • +
  • +

    Term remaining

    +

    {listing.remainingDays} days

    +
  • +
  • +

    Seller reputation

    +

    {sellerReputation.successRate}% success rate

    +
  • +
  • +

    Marketplace status

    +

    Active

    +
  • +
+
+ +
+

Seller reputation

+

+ Seller reputation is surfaced using marketplace performance and + compliance history. High-reputation listings typically have fewer + settlement or attestation issues. +

+
+ +
+
+ More details + + Browse similar listings + +
+
+
+
+ + +
+ ); +} diff --git a/src/components/MarketplaceListingPurchase.tsx b/src/components/MarketplaceListingPurchase.tsx new file mode 100644 index 00000000..ef29e29a --- /dev/null +++ b/src/components/MarketplaceListingPurchase.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState } from 'react'; +import { useWallet } from '@/hooks/useWallet'; + +interface MarketplaceListingPurchaseProps { + listingId: string; + price: string; +} + +type PreflightResponse = { + eligible: boolean; + reasons: string[]; +}; + +export function MarketplaceListingPurchase({ listingId, price }: MarketplaceListingPurchaseProps) { + const { connected, address, connect, error: walletError, connecting } = useWallet(); + const [statusMessage, setStatusMessage] = useState('Connect your wallet to check purchase eligibility.'); + const [eligible, setEligible] = useState(null); + const [reasons, setReasons] = useState([]); + const [isChecking, setIsChecking] = useState(false); + const [isPurchasing, setIsPurchasing] = useState(false); + const [purchaseResult, setPurchaseResult] = useState(null); + + const handleCheckEligibility = async () => { + if (!address) { + setStatusMessage('Connect your wallet first to verify purchase eligibility.'); + return; + } + + setIsChecking(true); + setStatusMessage('Checking purchase eligibility...'); + setReasons([]); + + try { + const response = await fetch(`/api/marketplace/listings/${listingId}/preflight`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ buyerAddress: address }), + }); + + const body = await response.json(); + if (!response.ok) { + throw new Error(body.error?.message || 'Could not verify eligibility'); + } + + const result = body.data as PreflightResponse; + setEligible(result.eligible); + setReasons(result.reasons ?? []); + setStatusMessage( + result.eligible + ? 'You are eligible to purchase this listing. Proceed when ready.' + : 'Purchase is not currently eligible for your wallet.', + ); + } catch (error) { + setStatusMessage((error as Error).message || 'Unable to verify eligibility.'); + } finally { + setIsChecking(false); + } + }; + + const handlePurchase = async () => { + setIsPurchasing(true); + setPurchaseResult(null); + + try { + const response = await fetch(`/api/marketplace/listings/${listingId}/purchase`, { + method: 'POST', + credentials: 'include', + }); + const body = await response.json(); + if (!response.ok) { + throw new Error(body.error?.message || 'Purchase failed'); + } + + setPurchaseResult('Purchase request submitted successfully. Review the transaction in your wallet or account activity.'); + } catch (error) { + setPurchaseResult((error as Error).message || 'Purchase failed.'); + } finally { + setIsPurchasing(false); + } + }; + + return ( +
+
+

Selected listing

+

{price}

+

+ This listing uses the platform purchase flow to verify eligibility before completing a transaction. +

+
+ +
+
+

Purchase status

+ {connected ? ( + + Wallet connected + + ) : ( + + Wallet disconnected + + )} +
+ +

{statusMessage}

+ + {walletError && ( +

{walletError}

+ )} + +
+ + + +
+ + {reasons.length > 0 && ( +
+

Eligibility issues

+
    + {reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+
+ )} + + {purchaseResult && ( +
+ {purchaseResult} +
+ )} +
+
+ ); +} diff --git a/src/components/RecentAttestationsPanel/RecentAttestationsPanel.tsx b/src/components/RecentAttestationsPanel/RecentAttestationsPanel.tsx index 1087a02c..a872a1d6 100644 --- a/src/components/RecentAttestationsPanel/RecentAttestationsPanel.tsx +++ b/src/components/RecentAttestationsPanel/RecentAttestationsPanel.tsx @@ -177,7 +177,7 @@ export default function RecentAttestationsPanel({
{attestations.length === 0 ? ( -
+

No attestations available

) : ( @@ -188,7 +188,6 @@ export default function RecentAttestationsPanel({ className={`${styles.attestationRow} ${getSeverityClass(attestation.severity)}`} onClick={() => onSelectAttestation(attestation.id)} aria-label={`${attestation.severity} attestation: ${attestation.title}`} - role="listitem" >