From 9e8569cb08dce964ce420f7f7ca3d97261115049 Mon Sep 17 00:00:00 2001 From: tyler malin Date: Tue, 23 Jun 2026 07:25:15 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(web):=20Res-4=20address=E2=86=92hex=20?= =?UTF-8?q?resolver=20+=20Vitest=20infra=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveContainingCell(lat, lng) → canonical Res-4 H3 cell, works globally - add Vitest + test/test:watch scripts (slots into root test:all) - PRD for Address→Hex Lookup & Reserve feature Slice 1 core: pure resolver is TDD-covered (3 behavior specs). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/package.json | 7 +- apps/web/src/lib/hex-lookup.test.ts | 30 + apps/web/src/lib/hex-lookup.ts | 9 + apps/web/vitest.config.ts | 8 + docs/prd/address-hex-lookup.md | 120 +++ package-lock.json | 1312 +++++++++++++++++++++++++-- 6 files changed, 1392 insertions(+), 94 deletions(-) create mode 100644 apps/web/src/lib/hex-lookup.test.ts create mode 100644 apps/web/src/lib/hex-lookup.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 docs/prd/address-hex-lookup.md diff --git a/apps/web/package.json b/apps/web/package.json index c1566a4..909c597 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,9 @@ "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@magic-sdk/admin": "^2.8.2", @@ -38,6 +40,7 @@ "autoprefixer": "^10.4.19", "postcss": "^8.4.38", "tailwindcss": "^3.4.3", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } } diff --git a/apps/web/src/lib/hex-lookup.test.ts b/apps/web/src/lib/hex-lookup.test.ts new file mode 100644 index 0000000..8f6f59f --- /dev/null +++ b/apps/web/src/lib/hex-lookup.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { getResolution } from 'h3-js' +import { resolveContainingCell } from './hex-lookup' + +describe('resolveContainingCell', () => { + it('returns the Res-4 cell that contains a coordinate', () => { + // Los Angeles City Hall — its containing Res-4 cell is the Genesis West-Coast lab node. + const cell = resolveContainingCell(34.0537, -118.2428) + + expect(cell).toBe('8429a1dffffffff') + expect(getResolution(cell)).toBe(4) + }) + + it('resolves coordinates anywhere on Earth, not just the curated US set', () => { + // Brandenburg Gate, Berlin — outside the curated 200, must still resolve. + const cell = resolveContainingCell(52.5163, 13.3777) + + expect(cell).toBe('841f1d5ffffffff') + expect(getResolution(cell)).toBe(4) + }) + + it('maps two addresses in the same vicinity to the same cell, and a distant one to a different cell', () => { + const cityHall = resolveContainingCell(34.0537, -118.2428) + const nearby = resolveContainingCell(34.056, -118.245) // ~500m away + const distant = resolveContainingCell(52.5163, 13.3777) // Berlin + + expect(nearby).toBe(cityHall) + expect(distant).not.toBe(cityHall) + }) +}) diff --git a/apps/web/src/lib/hex-lookup.ts b/apps/web/src/lib/hex-lookup.ts new file mode 100644 index 0000000..a3d5493 --- /dev/null +++ b/apps/web/src/lib/hex-lookup.ts @@ -0,0 +1,9 @@ +import { latLngToCell } from 'h3-js' + +/** The H3 resolution sold in the current funding round (~1,770 km² per cell). */ +export const RES4 = 4 + +/** Resolve the canonical Res-4 H3 cell that contains the given coordinate. */ +export function resolveContainingCell(lat: number, lng: number): string { + return latLngToCell(lat, lng, RES4) +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..ceafc24 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/docs/prd/address-hex-lookup.md b/docs/prd/address-hex-lookup.md new file mode 100644 index 0000000..84842bc --- /dev/null +++ b/docs/prd/address-hex-lookup.md @@ -0,0 +1,120 @@ +# PRD: Address → Hex Lookup & Reserve (Res 4) + +Status: Draft · Owner: Tyler · App: `apps/web` (launch.malamalabs.com) + +## Context + +Buyers should be able to type their address, see the exact H3 cell that contains it, and +reserve it to purchase. The current presale lets people claim from a **curated set of 200 +Res-4 cells** (US-only, `regions.json`) selected on a Mapbox view — there is no way to look up +an arbitrary address and act on the cell that contains it. + +We already run real Uber H3 (`h3-js@4.1.0`) and a race-safe reservation/mint stack. The +genuinely new piece is **forward** address lookup (address → lat/lng → containing cell) and a +path for cells **outside** the curated 200. + +This is step one toward the larger goal: **a Res-4 owner can later fractionalize their cell +into its inner H3 children and resell them when the protocol makes a global resolution change +(Res 4 → 5 → 6 → 7).** Nothing built here may preclude that. + +## Goal + +Let a user enter any address worldwide, see the **Res-4** cell (1,770 km²) containing it, and: +- **If the cell is one of the curated 200 and available** → reserve + mint through the existing flow. +- **If the cell is outside the 200 (anywhere on Earth)** → place an off-chain **global hold** + (exclusive, free, email-anchored, 30-day expiry). No mint yet. + +## Non-goals (explicitly out of scope for this iteration) + +- No changes to the deployed `GenesisValidator.sol` contract. It is **frozen**. +- No fractionalization / secondary resale of inner hexes (future iteration — but the data model must support it). +- No new funding-round pricing tiers (Res 5/6/7, $2,500–$3,500). Current round is **Res 4 only**. +- No payment/deposit for global holds (they cannot mint, so they are free interest+lock). + +## Core model + +| Address resolves to a Res-4 cell that is… | Behavior | +|---|---| +| In curated 200, available | **Reserve + mint** — existing `issueClaim` → contract `secureNode`/`adminSecureNode`. Unchanged. | +| Outside curated 200 (global) | **Global hold** — new KV store, separate namespace, no edition consumed, no mint. | +| Already claimed (200) or already held (global) | "Taken" — show owner-less status, offer nearby alternatives. | +| Native/tribal (`NATIVE_HEX_SET`) | Blocked, as today. | + +**Invariants** +- The 200 Genesis editions and the on-chain `MAX_GENESIS_SUPPLY = 200` cap are untouched. +- Global holds live in their **own** KV namespace — never increment `genesis:issued`. +- Every mint **and** every hold is stored keyed by its **canonical Res-4 H3 index + owner**, + so a future global res change enables `cellToChildren(res4Index, n)` → fractionalize/resell. + +## User flow + +1. User opens the address-lookup surface (new component, e.g. `AddressHexLookup.tsx`). +2. Types an address → **Mapbox forward geocoding** autocomplete (Search Box / Geocoding v6 + forward; reuses `NEXT_PUBLIC_MAPBOX_TOKEN`). Picks a result → `{ lat, lng }`. +3. `latLngToCell(lat, lng, 4)` → canonical Res-4 index. Map flies to it; `cellToBoundary` draws it. +4. Status resolved via a single endpoint (curated-available / curated-taken / global-available / + global-held / native). Price via existing `calculateGenesisListingPriceDeterministic` (already global). +5. CTA depends on status: + - curated-available → enter existing presale/checkout with `?hex=`. + - global-available → "Reserve this cell" → email → creates an exclusive global hold. + - taken/native → disabled + nearby suggestions (`gridDisk(index, 1)` filtered to available). + +## Data model — new `global-hold-store.ts` (KV, mirrors `genesis-claim-registry.ts`) + +``` +GlobalHold { + hexId: string // canonical Res-4 H3 index (the fractionalization anchor) + resolution: 4 + email: string // owner identity (reuses email session) + lat, lng: number // geocoded centroid of the address (for display) + status: 'held' + heldAt: ISO // created + expiresAt: ISO // heldAt + 30d + referrerId?: string // KOL attribution, same as claims +} + +KV keys (separate namespace — never touches genesis:*): + hold:res4: -> GlobalHold (exclusive: setNX / atomic guard) + hold:email: -> Set (a user's holds) + hold:index -> Set (all active holds, for map overlay) +``` + +Exclusivity via atomic set-if-absent (mirror the `sadd`/lock pattern in +`genesis-claim-registry.ts` / `custodial-store.ts`). Expiry via stored `expiresAt` + +lazy reclaim on read (same idea as the 20-min orphaned-claim sweep). + +## Reuse (do not rebuild) + +- `h3-js`: `latLngToCell`, `cellToBoundary`, `cellToLatLng`, `gridDisk`, `cellToChildren` (future). +- Pricing: `calculateGenesisListingPriceDeterministic(lat, lng, hexId)` in `lib/h3.ts` — already global. +- Curated availability: `genesis-claim-registry.ts`, `regions.json`, `NATIVE_HEX_SET`. +- Map: `HexBoundaryPreview.tsx` (already renders a single cell + reverse-geocodes), `explorer` H3 utils. +- Checkout: existing presale/`create-session` for curated cells (relax nothing on-chain). + +## Vertical slices (candidate issues) + +1. **Forward geocode + resolve cell (read-only):** address autocomplete → `latLngToCell(…,4)` → + render the containing cell on the map. No reservation. Ships standalone value. +2. **Cell status endpoint:** `GET /api/hexes/resolve?hex=` → curated-available | + curated-taken | global-available | global-held | native, + price + centroid. +3. **Global hold store + API:** `global-hold-store.ts` + `POST/GET/DELETE /api/holds` (exclusive, + email-anchored, 30-day expiry). Separate KV namespace; unit-tested for the race + cap-isolation. +4. **Lookup UI wiring:** `AddressHexLookup.tsx` — status-driven CTA (mint route vs hold vs taken), + nearby suggestions via `gridDisk`. +5. **Global holds on the map (optional):** overlay active holds as a distinct layer on `/map` / `/explorer`. + +## Risks / edge cases + +- Address geocodes to ocean/unpopulated cell → allow (it's still a valid Res-4 index) but flag water via existing `hex-geo` sampling. +- Curated cell membership is currently checked only in `create-session` — confirm we don't accidentally let a global cell enter the *mint* path (it must route to holds, not `issueClaim`). +- Resolution drift: assert `getResolution(index) === 4` everywhere a Res-4 index is expected. +- Hold → future mint conversion is out of scope, but store enough (`hexId`, `email`, `referrerId`) to convert later without data loss. + +## Verification + +- Unit: `global-hold-store` — exclusive lock under concurrent holds; expiry reclaim; never touches `genesis:issued`. +- Integration: resolve endpoint returns correct status for (a) a known curated cell, (b) a known + taken cell, (c) an arbitrary global address (e.g. Berlin), (d) a native cell. +- Manual (run the app): type a US curated address → reserve+mint path; type an overseas address → + global hold path; re-enter the same overseas address from another email → "taken." +- Regression: existing presale/mint flow for the curated 200 is byte-for-byte unchanged. diff --git a/package-lock.json b/package-lock.json index 9ce9a14..2249d37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,8 @@ "autoprefixer": "^10.4.19", "postcss": "^8.4.38", "tailwindcss": "^3.4.3", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } }, "apps/web/node_modules/@types/node": { @@ -8616,6 +8617,46 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz", + "integrity": "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz", + "integrity": "sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-linux-x64-gnu": { "version": "1.0.0-rc.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.1.tgz", @@ -10066,6 +10107,13 @@ "antlr4ts": "^0.5.0-alpha.4" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@stricahq/bip32ed25519": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@stricahq/bip32ed25519/-/bip32ed25519-1.1.2.tgz", @@ -12676,6 +12724,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/docker-modem": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", @@ -12791,14 +12846,20 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", - "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~8.3.0" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, "node_modules/@types/pbf": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", @@ -13261,6 +13322,16 @@ "undici": "5.28.4" } }, + "node_modules/@vercel/node/node_modules/@types/node": { + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", + "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/@vercel/node/node_modules/async-listen": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.0.tgz", @@ -13591,6 +13662,160 @@ "ts-morph": "12.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@vitest/expect/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@vitest/expect/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wagmi/connectors": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/@wagmi/connectors/-/connectors-6.2.0.tgz", @@ -17461,8 +17686,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cookie": { "version": "0.4.2", @@ -19514,6 +19738,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extension-port-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/extension-port-stream/-/extension-port-stream-3.0.0.tgz", @@ -22109,63 +22343,336 @@ "immediate": "~3.0.5" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/lit": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz", - "integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit/reactive-element": "^2.1.0", - "lit-element": "^4.2.0", - "lit-html": "^3.3.0" + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lit-element": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", - "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.5.0", - "@lit/reactive-element": "^2.1.0", - "lit-html": "^3.3.0" + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lit-html": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", - "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", - "license": "BSD-3-Clause", - "dependencies": { - "@types/trusted-types": "^2.0.2" + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/load-ip-set": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/load-ip-set/-/load-ip-set-3.0.1.tgz", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lit": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz", + "integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/load-ip-set": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/load-ip-set/-/load-ip-set-3.0.1.tgz", "integrity": "sha512-ZFZt1g4Exq01SFtKjffqau+L4Qibt+51utymHHiWo8Iu/W7LYSqE7fiZ/iAZ6dIqbmeU6ICSIK02IizSScBkLQ==", "funding": [ { @@ -22383,6 +22890,16 @@ "localforage": "^1.7.4" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/magnet-uri": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/magnet-uri/-/magnet-uri-7.0.7.tgz", @@ -23109,9 +23626,9 @@ "license": "ISC" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -26592,6 +27109,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ofetch": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", @@ -27063,6 +27594,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pathval": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", @@ -27264,9 +27802,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -27283,7 +27821,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -29317,6 +29855,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", @@ -29918,6 +30463,13 @@ "nan": "^2.23.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stacktrace-parser": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", @@ -29958,6 +30510,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -30776,6 +31335,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -30784,9 +31350,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -30805,6 +31371,16 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -31898,44 +32474,579 @@ } } }, - "node_modules/vm-browserify": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", - "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", - "license": "MIT" - }, - "node_modules/wagmi": { - "version": "2.19.5", - "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.19.5.tgz", - "integrity": "sha512-RQUfKMv6U+EcSNNGiPbdkDtJwtuFxZWLmvDiQmjjBgkuPulUwDJsKhi7gjynzJdsx2yDqhHCXkKsbbfbIsHfcQ==", + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, "license": "MIT", "dependencies": { - "@wagmi/connectors": "6.2.0", - "@wagmi/core": "2.22.1", - "use-sync-external-store": "1.4.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/wevm" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@tanstack/react-query": ">=5.0.0", - "react": ">=18", - "typescript": ">=5.0.4", - "viem": "2.x" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "typescript": { + "@types/node": { "optional": true - } - } - }, - "node_modules/wagmi/node_modules/use-sync-external-store": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", - "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz", + "integrity": "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz", + "integrity": "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz", + "integrity": "sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz", + "integrity": "sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz", + "integrity": "sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz", + "integrity": "sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz", + "integrity": "sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz", + "integrity": "sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz", + "integrity": "sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz", + "integrity": "sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz", + "integrity": "sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz", + "integrity": "sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz", + "integrity": "sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.2.tgz", + "integrity": "sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.2", + "@rolldown/binding-darwin-arm64": "1.1.2", + "@rolldown/binding-darwin-x64": "1.1.2", + "@rolldown/binding-freebsd-x64": "1.1.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.2", + "@rolldown/binding-linux-arm64-gnu": "1.1.2", + "@rolldown/binding-linux-arm64-musl": "1.1.2", + "@rolldown/binding-linux-ppc64-gnu": "1.1.2", + "@rolldown/binding-linux-s390x-gnu": "1.1.2", + "@rolldown/binding-linux-x64-gnu": "1.1.2", + "@rolldown/binding-linux-x64-musl": "1.1.2", + "@rolldown/binding-openharmony-arm64": "1.1.2", + "@rolldown/binding-wasm32-wasi": "1.1.2", + "@rolldown/binding-win32-arm64-msvc": "1.1.2", + "@rolldown/binding-win32-x64-msvc": "1.1.2" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "license": "MIT" + }, + "node_modules/wagmi": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.19.5.tgz", + "integrity": "sha512-RQUfKMv6U+EcSNNGiPbdkDtJwtuFxZWLmvDiQmjjBgkuPulUwDJsKhi7gjynzJdsx2yDqhHCXkKsbbfbIsHfcQ==", + "license": "MIT", + "dependencies": { + "@wagmi/connectors": "6.2.0", + "@wagmi/core": "2.22.1", + "use-sync-external-store": "1.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "@tanstack/react-query": ">=5.0.0", + "react": ">=18", + "typescript": ">=5.0.4", + "viem": "2.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/wagmi/node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/web-encoding": { @@ -32287,6 +33398,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", From c06717151c9b10c8e36c2f8a659e16ab732e5b27 Mon Sep 17 00:00:00 2001 From: tyler malin Date: Tue, 23 Jun 2026 07:27:56 -0700 Subject: [PATCH 2/7] feat(web): address autocomplete + hex lookup UI (#39) - parseForwardSuggestions(): pure parser for Mapbox v6 forward-geocode (TDD, 3 specs) - AddressHexLookup.tsx: debounced address autocomplete -> resolveContainingCell -> composes existing HexBoundaryPreview to render the containing Res-4 cell - /lookup demo route (non-invasive; embed the component into presale/map later) Slice 1 complete: address -> containing Res-4 hex, read-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/app/lookup/page.tsx | 20 +++ apps/web/src/components/AddressHexLookup.tsx | 153 +++++++++++++++++++ apps/web/src/lib/mapbox-geocode.test.ts | 39 +++++ apps/web/src/lib/mapbox-geocode.ts | 47 ++++++ 4 files changed, 259 insertions(+) create mode 100644 apps/web/src/app/lookup/page.tsx create mode 100644 apps/web/src/components/AddressHexLookup.tsx create mode 100644 apps/web/src/lib/mapbox-geocode.test.ts create mode 100644 apps/web/src/lib/mapbox-geocode.ts diff --git a/apps/web/src/app/lookup/page.tsx b/apps/web/src/app/lookup/page.tsx new file mode 100644 index 0000000..674700d --- /dev/null +++ b/apps/web/src/app/lookup/page.tsx @@ -0,0 +1,20 @@ +import AddressHexLookup from '@/components/AddressHexLookup' + +export const metadata = { + title: 'Address → Hex Lookup', + description: 'Find the Res-4 hex cell that contains your address.', +} + +export default function LookupPage() { + return ( +
+

Find your hex

+

+ Enter your address to see the Res-4 H3 cell (~1,770 km²) that contains it. +

+
+ +
+
+ ) +} diff --git a/apps/web/src/components/AddressHexLookup.tsx b/apps/web/src/components/AddressHexLookup.tsx new file mode 100644 index 0000000..870e41d --- /dev/null +++ b/apps/web/src/components/AddressHexLookup.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { Search, Loader2, MapPin, Hexagon } from 'lucide-react' +import HexBoundaryPreview from './HexBoundaryPreview' +import { resolveContainingCell } from '@/lib/hex-lookup' +import { parseForwardSuggestions, type AddressSuggestion } from '@/lib/mapbox-geocode' + +type Selected = { + hexId: string + label: string + sublabel: string +} + +/** + * Address → Hex lookup (Slice 1, read-only). + * Type an address, pick a suggestion, and see the Res-4 H3 cell that contains it. + * Reservation / status CTAs land in later slices. + */ +export default function AddressHexLookup({ className = '' }: { className?: string }) { + const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN + + const [query, setQuery] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [loading, setLoading] = useState(false) + const [selected, setSelected] = useState(null) + const [open, setOpen] = useState(false) + const justSelected = useRef(false) + + // Debounced forward geocode (Mapbox Search API v6 forward, autocomplete). + useEffect(() => { + if (!token) return + if (justSelected.current) { + justSelected.current = false + return + } + const q = query.trim() + if (q.length < 3) { + setSuggestions([]) + return + } + + let cancelled = false + setLoading(true) + const handle = setTimeout(async () => { + try { + const params = new URLSearchParams({ + q, + autocomplete: 'true', + limit: '5', + access_token: token, + }) + const res = await fetch(`https://api.mapbox.com/search/geocode/v6/forward?${params}`) + const data = await res.json() + if (cancelled) return + setSuggestions(parseForwardSuggestions(data)) + setOpen(true) + } catch { + if (!cancelled) setSuggestions([]) + } finally { + if (!cancelled) setLoading(false) + } + }, 300) + + return () => { + cancelled = true + clearTimeout(handle) + } + }, [query, token]) + + function pick(s: AddressSuggestion) { + const hexId = resolveContainingCell(s.lat, s.lng) + justSelected.current = true + setSelected({ hexId, label: s.label, sublabel: s.sublabel }) + setQuery(s.label) + setSuggestions([]) + setOpen(false) + } + + if (!token) { + return ( +
+ + Add NEXT_PUBLIC_MAPBOX_TOKEN to enable address lookup. +
+ ) + } + + return ( +
+ + +
+
+ + setQuery(e.target.value)} + onFocus={() => suggestions.length > 0 && setOpen(true)} + placeholder="Enter a street address…" + autoComplete="off" + className="w-full bg-transparent text-sm text-white placeholder-gray-600 outline-none" + /> + {loading && } +
+ + {open && suggestions.length > 0 && ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ )} +
+ + {selected && ( +
+
+ +
+

{selected.label} sits in this Res-4 hex:

+

{selected.hexId}

+
+
+ +
+ )} +
+ ) +} diff --git a/apps/web/src/lib/mapbox-geocode.test.ts b/apps/web/src/lib/mapbox-geocode.test.ts new file mode 100644 index 0000000..1e67659 --- /dev/null +++ b/apps/web/src/lib/mapbox-geocode.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { parseForwardSuggestions } from './mapbox-geocode' + +// Shape of a Mapbox Search API v6 forward-geocode feature. +const feature = (over: Record = {}) => ({ + geometry: { type: 'Point', coordinates: [-118.2428, 34.0537] }, + properties: { + mapbox_id: 'addr.123', + name: '200 N Spring St', + full_address: '200 N Spring St, Los Angeles, California 90012, United States', + place_formatted: 'Los Angeles, California 90012, United States', + ...over, + }, +}) + +describe('parseForwardSuggestions', () => { + it('maps forward-geocode features to selectable suggestions with coordinates', () => { + const [s] = parseForwardSuggestions({ features: [feature()] }) + + expect(s.id).toBe('addr.123') + expect(s.label).toBe('200 N Spring St') + expect(s.sublabel).toBe('Los Angeles, California 90012, United States') + expect(s.lat).toBeCloseTo(34.0537) + expect(s.lng).toBeCloseTo(-118.2428) + }) + + it('drops features without point coordinates', () => { + const broken = { geometry: null, properties: { mapbox_id: 'x', name: 'No geo' } } + const result = parseForwardSuggestions({ features: [broken, feature()] }) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('addr.123') + }) + + it('returns an empty list when there are no features', () => { + expect(parseForwardSuggestions({})).toEqual([]) + expect(parseForwardSuggestions({ features: [] })).toEqual([]) + }) +}) diff --git a/apps/web/src/lib/mapbox-geocode.ts b/apps/web/src/lib/mapbox-geocode.ts new file mode 100644 index 0000000..27f85ec --- /dev/null +++ b/apps/web/src/lib/mapbox-geocode.ts @@ -0,0 +1,47 @@ +/** + * Parsing for the Mapbox Search API v6 forward-geocode response. + * Network I/O lives in the component; this module only shapes the response + * into selectable address suggestions, so it can be unit-tested in isolation. + */ + +export type AddressSuggestion = { + /** Mapbox feature id (stable key for the list). */ + id: string + /** Primary line, e.g. the street address or place name. */ + label: string + /** Secondary line, e.g. "Los Angeles, California 90012, United States". */ + sublabel: string + lat: number + lng: number +} + +type V6ForwardFeature = { + geometry?: { type?: string; coordinates?: number[] } | null + properties?: { + mapbox_id?: string + name?: string + full_address?: string + place_formatted?: string + } +} + +export function parseForwardSuggestions(data: { features?: V6ForwardFeature[] }): AddressSuggestion[] { + const features = data?.features ?? [] + const out: AddressSuggestion[] = [] + + for (const f of features) { + const coords = f.geometry?.coordinates + if (!coords || coords.length < 2) continue + + const props = f.properties ?? {} + out.push({ + id: props.mapbox_id || props.full_address || props.name || `${coords[1]},${coords[0]}`, + label: props.name || props.full_address || 'Unknown location', + sublabel: props.place_formatted || props.full_address || '', + lng: coords[0], + lat: coords[1], + }) + } + + return out +} From 4bb4a847b6aea4ab764f4134ba6dac7544368662 Mon Sep 17 00:00:00 2001 From: tyler malin Date: Tue, 23 Jun 2026 07:48:35 -0700 Subject: [PATCH 3/7] feat(web): global hex hold store + /api/holds (#41) - global-hold-store.ts: exclusive off-chain Res-4 holds in a separate hold:* KV namespace; atomic sadd lock, 30-day TTL with lazy expiry reclaim, owner-only release, list-by-email. Never touches genesis:issued (isolation spec locks it). - /api/holds POST/GET/DELETE; server derives the cell from lat/lng and blocks native/tribal cells. force-dynamic. - vitest: add @ -> src path alias (needed once a tested module imports @/lib/*). Slice 3 of address-hex-lookup. 6 store specs (12 total green). Smoke-tested live: POST 201, duplicate 409, list, non-owner DELETE 403. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/app/api/holds/route.ts | 87 +++++++++++++ apps/web/src/lib/global-hold-store.test.ts | 85 +++++++++++++ apps/web/src/lib/global-hold-store.ts | 138 +++++++++++++++++++++ apps/web/vitest.config.ts | 6 + 4 files changed, 316 insertions(+) create mode 100644 apps/web/src/app/api/holds/route.ts create mode 100644 apps/web/src/lib/global-hold-store.test.ts create mode 100644 apps/web/src/lib/global-hold-store.ts diff --git a/apps/web/src/app/api/holds/route.ts b/apps/web/src/app/api/holds/route.ts new file mode 100644 index 0000000..bd3f19b --- /dev/null +++ b/apps/web/src/app/api/holds/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server' +import { + issueGlobalHold, + getGlobalHold, + releaseGlobalHold, + listHoldsByEmail, +} from '@/lib/global-hold-store' +import { resolveContainingCell } from '@/lib/hex-lookup' +import nativeHexesData from '@/data/genesis-native-hexes.json' + +const NATIVE_HEX_SET = new Set(Object.keys(nativeHexesData as Record)) + +export const dynamic = 'force-dynamic' + +/** + * Global hex holds — off-chain exclusive reservations of Res-4 cells outside the + * curated Genesis 200. Never mints, never consumes a Genesis edition. + * + * POST { email, lat, lng, referrerId? } → place a hold (server derives the cell) + * GET ?email=… → a user's holds | ?hex=… → a single cell's hold + * DELETE { hexId, email } → release your own hold + */ +export async function POST(req: Request) { + try { + const { email, lat, lng, referrerId } = await req.json() + + if (!email || typeof email !== 'string') { + return NextResponse.json({ error: 'Missing required field: email' }, { status: 400 }) + } + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + return NextResponse.json({ error: 'lat and lng must be numbers' }, { status: 400 }) + } + + // Derive the cell server-side so a hold always matches its coordinates. + const hexId = resolveContainingCell(lat, lng) + + // Native-reserved cells are held for Native Tribes first — not publicly holdable. + if (NATIVE_HEX_SET.has(hexId)) { + return NextResponse.json( + { error: 'This cell is on tribal land and reserved for Native Tribes', nativeReserved: true }, + { status: 403 }, + ) + } + + const result = await issueGlobalHold({ hexId, email, lat, lng, referrerId }) + if (!result.ok) { + return NextResponse.json( + { error: result.error, hexId, alreadyHeld: true }, + { status: 409 }, + ) + } + + return NextResponse.json({ ok: true, hold: result.hold }, { status: 201 }) + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }) + } +} + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const email = searchParams.get('email') + const hex = searchParams.get('hex') + + if (hex) { + return NextResponse.json({ hold: await getGlobalHold(hex) }) + } + if (email) { + return NextResponse.json({ holds: await listHoldsByEmail(email) }) + } + return NextResponse.json({ error: 'Provide ?email= or ?hex=' }, { status: 400 }) +} + +export async function DELETE(req: Request) { + try { + const { hexId, email } = await req.json() + if (!hexId || !email) { + return NextResponse.json({ error: 'Missing required fields: hexId, email' }, { status: 400 }) + } + const result = await releaseGlobalHold(hexId, email) + if (!result.ok) { + return NextResponse.json({ error: result.reason }, { status: 403 }) + } + return NextResponse.json({ ok: true }) + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }) + } +} diff --git a/apps/web/src/lib/global-hold-store.test.ts b/apps/web/src/lib/global-hold-store.test.ts new file mode 100644 index 0000000..3ee1e3c --- /dev/null +++ b/apps/web/src/lib/global-hold-store.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest' +import { issueGlobalHold, getGlobalHold, releaseGlobalHold, listHoldsByEmail } from './global-hold-store' +import { getStats } from './genesis-claim-registry' + +// Distinct global Res-4 cells per test — memKv persists in-process between tests, +// so each test owns a unique cell to stay isolated. +const BERLIN = '841f1d5ffffffff' + +describe('global-hold-store', () => { + it('places an exclusive hold on a global Res-4 cell and reads it back', async () => { + const res = await issueGlobalHold({ hexId: BERLIN, email: 'a@example.com', lat: 52.5163, lng: 13.3777 }) + + expect(res.ok).toBe(true) + + const hold = await getGlobalHold(BERLIN) + expect(hold?.hexId).toBe(BERLIN) + expect(hold?.email).toBe('a@example.com') + expect(hold?.status).toBe('held') + expect(hold?.resolution).toBe(4) + }) + + it('rejects a second hold on a cell already held by someone else', async () => { + const NYC = '842a101ffffffff' + const first = await issueGlobalHold({ hexId: NYC, email: 'a@example.com', lat: 40.7484, lng: -73.9857 }) + expect(first.ok).toBe(true) + + const second = await issueGlobalHold({ hexId: NYC, email: 'b@example.com', lat: 40.7484, lng: -73.9857 }) + expect(second.ok).toBe(false) + if (!second.ok) { + expect(second.error).toMatch(/already held/i) + expect(second.existing?.email).toBe('a@example.com') + } + }) + + it('lets a cell be re-held by someone else after the hold expires', async () => { + const HON = '84464b9ffffffff' + const t0 = Date.parse('2026-01-01T00:00:00Z') + + const first = await issueGlobalHold({ hexId: HON, email: 'a@example.com', lat: 21.3, lng: -157.8 }, { now: t0 }) + expect(first.ok).toBe(true) + + const past = t0 + 31 * 24 * 60 * 60 * 1000 // 31 days later — past the 30-day TTL + expect(await getGlobalHold(HON, { now: past })).toBeNull() + + const reheld = await issueGlobalHold({ hexId: HON, email: 'b@example.com', lat: 21.3, lng: -157.8 }, { now: past }) + expect(reheld.ok).toBe(true) + expect((await getGlobalHold(HON, { now: past }))?.email).toBe('b@example.com') + }) + + it('never consumes a Genesis edition — the 200-cap counter is untouched', async () => { + const before = await getStats() + await issueGlobalHold({ hexId: '842a107ffffffff', email: 'iso@example.com', lat: 40.67, lng: -73.98 }) + const after = await getStats() + + expect(after.issued).toBe(before.issued) + expect(after.issued).toBe(0) + }) + + it('lets the owner release a hold (and refuses non-owners), freeing the cell', async () => { + const LDN = '84194adffffffff' + await issueGlobalHold({ hexId: LDN, email: 'owner@example.com', lat: 51.5, lng: -0.12 }) + + const denied = await releaseGlobalHold(LDN, 'intruder@example.com') + expect(denied.ok).toBe(false) + expect(await getGlobalHold(LDN)).not.toBeNull() + + const released = await releaseGlobalHold(LDN, 'owner@example.com') + expect(released.ok).toBe(true) + expect(await getGlobalHold(LDN)).toBeNull() + + const reheld = await issueGlobalHold({ hexId: LDN, email: 'new@example.com', lat: 51.5, lng: -0.12 }) + expect(reheld.ok).toBe(true) + }) + + it('lists a user’s active holds', async () => { + const TYO = '842f5a3ffffffff' + const SF = '8428309ffffffff' + await issueGlobalHold({ hexId: TYO, email: 'multi@example.com', lat: 35.6, lng: 139.6 }) + await issueGlobalHold({ hexId: SF, email: 'multi@example.com', lat: 37.7, lng: -122.4 }) + + const holds = await listHoldsByEmail('multi@example.com') + expect(holds.map((h) => h.hexId).sort()).toEqual([SF, TYO].sort()) + expect(holds.every((h) => h.email === 'multi@example.com')).toBe(true) + }) +}) diff --git a/apps/web/src/lib/global-hold-store.ts b/apps/web/src/lib/global-hold-store.ts new file mode 100644 index 0000000..4aa2859 --- /dev/null +++ b/apps/web/src/lib/global-hold-store.ts @@ -0,0 +1,138 @@ +/** + * KV-backed store for GLOBAL hex holds — off-chain, exclusive reservations of + * Res-4 cells OUTSIDE the curated Genesis 200. These never mint and never consume + * a Genesis edition; they live in their own `hold:*` namespace so the live mint + * path (`genesis:*`) is completely untouched. + * + * Atomicity mirrors genesis-claim-registry: sadd(K.held, hexId) is a single Redis + * command (1 on first insert, 0 if already held), making holds race-safe. + * + * Every hold is keyed by its canonical Res-4 H3 index + owner email, so a future + * global resolution change can fractionalize the cell via cellToChildren(). + */ + +import { kv } from '@/lib/kv' +import { RES4 } from '@/lib/hex-lookup' + +export const HOLD_TTL_DAYS = 30 +const HOLD_TTL_MS = HOLD_TTL_DAYS * 24 * 60 * 60 * 1000 + +export type GlobalHold = { + hexId: string + resolution: typeof RES4 + email: string + lat: number + lng: number + status: 'held' + heldAt: string + expiresAt: string + referrerId?: string +} + +// ── KV key schema (separate `hold:*` namespace — never touches `genesis:*`) ────── +// hold:held → Set (atomic exclusivity via sadd) +// hold:detail: → GlobalHold +// hold:email: → Set +// hold:index → Set (all holds, for the map overlay) +const K = { + held: 'hold:held', + index: 'hold:index', + detail: (hexId: string) => `hold:detail:${hexId}`, + email: (email: string) => `hold:email:${email}`, +} + +export type IssueHoldInput = { + hexId: string + email: string + lat: number + lng: number + referrerId?: string +} + +/** `now` is injectable so expiry is deterministically testable; defaults to wall clock. */ +type ClockOpts = { now?: number } + +/** Read a hold only if it hasn't expired as of `now`. Pure read, no mutation. */ +async function readLiveHold(hexId: string, now: number): Promise { + const hold = await kv.get(K.detail(hexId)) + if (!hold) return null + return Date.parse(hold.expiresAt) <= now ? null : hold +} + +export async function issueGlobalHold( + input: IssueHoldInput, + opts?: ClockOpts, +): Promise<{ ok: true; hold: GlobalHold } | { ok: false; error: string; existing?: GlobalHold }> { + const now = opts?.now ?? Date.now() + + // sadd is atomic: 0 means the cell was already in the held-set. + const added = await kv.sadd(K.held, input.hexId) + if (added === 0) { + const live = await readLiveHold(input.hexId, now) + if (live) return { ok: false, error: 'Cell already held', existing: live } + // Expired or orphaned — reclaim in place (the set member is already present). + } + + const hold: GlobalHold = { + hexId: input.hexId, + resolution: RES4, + email: input.email, + lat: input.lat, + lng: input.lng, + status: 'held', + heldAt: new Date(now).toISOString(), + expiresAt: new Date(now + HOLD_TTL_MS).toISOString(), + referrerId: input.referrerId, + } + + await Promise.all([ + kv.set(K.detail(input.hexId), hold), + kv.sadd(K.email(input.email), input.hexId), + kv.sadd(K.index, input.hexId), + ]) + + return { ok: true, hold } +} + +/** All of a user's currently-active holds (expired ones are filtered and cleaned). */ +export async function listHoldsByEmail(email: string, opts?: ClockOpts): Promise { + const ids = await kv.smembers(K.email(email)) + const holds = await Promise.all(ids.map((id) => getGlobalHold(id, opts))) + return holds.filter((h): h is GlobalHold => h !== null) +} + +/** Release a hold. Only the owning email may release it. */ +export async function releaseGlobalHold( + hexId: string, + email: string, +): Promise<{ ok: boolean; reason?: string }> { + const existing = await kv.get(K.detail(hexId)) + if (existing && existing.email !== email) { + return { ok: false, reason: 'Only the holder can release this cell' } + } + await Promise.all([ + kv.srem(K.held, hexId), + kv.del(K.detail(hexId)), + kv.srem(K.index, hexId), + ...(existing ? [kv.srem(K.email(existing.email), hexId)] : []), + ]) + return { ok: true } +} + +export async function getGlobalHold(hexId: string, opts?: ClockOpts): Promise { + const now = opts?.now ?? Date.now() + const live = await readLiveHold(hexId, now) + if (live) return live + + // Expired record lingering in KV — lazily clean up every index so the cell frees. + const stale = await kv.get(K.detail(hexId)) + if (stale) { + await Promise.all([ + kv.srem(K.held, hexId), + kv.del(K.detail(hexId)), + kv.srem(K.email(stale.email), hexId), + kv.srem(K.index, hexId), + ]) + } + return null +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index ceafc24..3bbb4db 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, test: { environment: 'node', include: ['src/**/*.test.ts'], From c9d86dad4d744980d0c1dbeccf095351bfa72d29 Mon Sep 17 00:00:00 2001 From: tyler malin Date: Tue, 23 Jun 2026 07:58:02 -0700 Subject: [PATCH 4/7] feat(web): cell status resolver + /api/hexes/resolve (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - classifyCellStatus(): pure status decision — native | curated-available | curated-taken | global-available | global-held, with native precedence. - resolveCellStatus(): wires curated/native sets + claim + hold + price + centroid; rejects invalid H3 cells via isValidCell (bug caught in live smoke test where cellToLatLng returned garbage instead of throwing). - GET /api/hexes/resolve?hex= or ?lat=&lng= (derives the Res-4 cell). Slice 2 of address-hex-lookup. 6 status specs (18 total green). Smoke-tested live: curated-available, native, global-available, global-held (after a hold), invalid -> 400. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/app/api/hexes/resolve/route.ts | 33 ++++++++++ apps/web/src/lib/cell-status.test.ts | 33 ++++++++++ apps/web/src/lib/cell-status.ts | 70 +++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 apps/web/src/app/api/hexes/resolve/route.ts create mode 100644 apps/web/src/lib/cell-status.test.ts create mode 100644 apps/web/src/lib/cell-status.ts diff --git a/apps/web/src/app/api/hexes/resolve/route.ts b/apps/web/src/app/api/hexes/resolve/route.ts new file mode 100644 index 0000000..623300f --- /dev/null +++ b/apps/web/src/app/api/hexes/resolve/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' +import { resolveCellStatus } from '@/lib/cell-status' +import { resolveContainingCell } from '@/lib/hex-lookup' + +export const dynamic = 'force-dynamic' + +/** + * Resolve a cell's reservation status for the address-lookup flow. + * GET ?hex=

→ status of that cell + * GET ?lat=&lng= → status of the Res-4 cell containing the point + * Returns { hexId, resolution, status, lat, lng, priceUsd }. + */ +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const hexParam = searchParams.get('hex') + const lat = Number(searchParams.get('lat')) + const lng = Number(searchParams.get('lng')) + + let hexId: string + if (hexParam) { + hexId = hexParam + } else if (Number.isFinite(lat) && Number.isFinite(lng)) { + hexId = resolveContainingCell(lat, lng) + } else { + return NextResponse.json({ error: 'Provide ?hex= or ?lat=&lng=' }, { status: 400 }) + } + + try { + return NextResponse.json(await resolveCellStatus(hexId)) + } catch { + return NextResponse.json({ error: 'Invalid H3 cell' }, { status: 400 }) + } +} diff --git a/apps/web/src/lib/cell-status.test.ts b/apps/web/src/lib/cell-status.test.ts new file mode 100644 index 0000000..cf521da --- /dev/null +++ b/apps/web/src/lib/cell-status.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { classifyCellStatus, resolveCellStatus } from './cell-status' + +describe('classifyCellStatus', () => { + it('marks an unclaimed curated cell as curated-available', () => { + expect(classifyCellStatus({ isNative: false, isCurated: true, isClaimed: false, isHeld: false })) + .toBe('curated-available') + }) + + it('marks a claimed curated cell as curated-taken', () => { + expect(classifyCellStatus({ isNative: false, isCurated: true, isClaimed: true, isHeld: false })) + .toBe('curated-taken') + }) + + it('marks an unheld non-curated cell as global-available', () => { + expect(classifyCellStatus({ isNative: false, isCurated: false, isClaimed: false, isHeld: false })) + .toBe('global-available') + }) + + it('marks a held non-curated cell as global-held', () => { + expect(classifyCellStatus({ isNative: false, isCurated: false, isClaimed: false, isHeld: true })) + .toBe('global-held') + }) + + it('treats native cells as native regardless of any other flag', () => { + expect(classifyCellStatus({ isNative: true, isCurated: true, isClaimed: true, isHeld: true })) + .toBe('native') + }) + + it('rejects an invalid H3 cell instead of returning garbage', async () => { + await expect(resolveCellStatus('not-a-hex')).rejects.toThrow() + }) +}) diff --git a/apps/web/src/lib/cell-status.ts b/apps/web/src/lib/cell-status.ts new file mode 100644 index 0000000..dec8c8f --- /dev/null +++ b/apps/web/src/lib/cell-status.ts @@ -0,0 +1,70 @@ +/** + * Cell status resolution for the address-lookup flow. + * + * Pure `classifyCellStatus` decides the status from already-gathered facts (TDD-covered); + * async `resolveCellStatus` gathers those facts (curated membership, claim, hold, native) + * and adds price + centroid for the UI. + */ + +import { cellToLatLng, getResolution, isValidCell } from 'h3-js' +import { calculateGenesisListingPriceDeterministic } from '@/lib/h3' +import { getClaimByHex } from '@/lib/genesis-claim-registry' +import { getGlobalHold } from '@/lib/global-hold-store' +import regionsData from '@/data/regions.json' +import nativeHexesData from '@/data/genesis-native-hexes.json' + +export type CellStatus = + | 'native' + | 'curated-available' + | 'curated-taken' + | 'global-available' + | 'global-held' + +export function classifyCellStatus(facts: { + isNative: boolean + isCurated: boolean + isClaimed: boolean + isHeld: boolean +}): CellStatus { + if (facts.isNative) return 'native' + if (facts.isCurated) return facts.isClaimed ? 'curated-taken' : 'curated-available' + return facts.isHeld ? 'global-held' : 'global-available' +} + +// Built once at module load — the curated 200 and native sets are static data. +const CURATED_HEX_SET = new Set( + Object.values(regionsData as Record).flatMap((r) => r?.cells ?? []), +) +const NATIVE_HEX_SET = new Set(Object.keys(nativeHexesData as Record)) + +export type CellStatusResult = { + hexId: string + resolution: number + status: CellStatus + lat: number + lng: number + priceUsd: number +} + +export async function resolveCellStatus(hexId: string): Promise { + if (!isValidCell(hexId)) throw new Error(`Invalid H3 cell: ${hexId}`) + const [lat, lng] = cellToLatLng(hexId) + + const [claim, hold] = await Promise.all([getClaimByHex(hexId), getGlobalHold(hexId)]) + + const status = classifyCellStatus({ + isNative: NATIVE_HEX_SET.has(hexId), + isCurated: CURATED_HEX_SET.has(hexId), + isClaimed: !!claim, + isHeld: !!hold, + }) + + return { + hexId, + resolution: getResolution(hexId), + status, + lat, + lng, + priceUsd: calculateGenesisListingPriceDeterministic(lat, lng, hexId), + } +} From 6c2ac6c5377cdc4084ec77a0ec1578aa008fde4b Mon Sep 17 00:00:00 2001 From: tyler malin Date: Tue, 23 Jun 2026 08:06:02 -0700 Subject: [PATCH 5/7] =?UTF-8?q?feat(web):=20status-driven=20lookup=20UI=20?= =?UTF-8?q?=E2=80=94=20mint=20/=20hold=20/=20nearby=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cellStatusCta(): pure status -> CTA mapping (mint | hold | disabled). - neighborCells(): gridDisk(1) ring helper for nearby suggestions. - AddressHexLookup: resolves cell status on select; shows price + status CTA. curated-available -> link to /presale?hex=; global-available -> free 30-day hold form (POST /api/holds); taken/native -> disabled + available neighbours. Slice 4 of address-hex-lookup. 9 new specs (23 total green). /lookup compiles clean; /presale?hex= target verified live. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/AddressHexLookup.tsx | 192 ++++++++++++++----- apps/web/src/lib/cell-status.test.ts | 28 ++- apps/web/src/lib/cell-status.ts | 23 +++ apps/web/src/lib/hex-lookup.test.ts | 13 +- apps/web/src/lib/hex-lookup.ts | 7 +- 5 files changed, 217 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/AddressHexLookup.tsx b/apps/web/src/components/AddressHexLookup.tsx index 870e41d..5dff0a0 100644 --- a/apps/web/src/components/AddressHexLookup.tsx +++ b/apps/web/src/components/AddressHexLookup.tsx @@ -1,21 +1,19 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Search, Loader2, MapPin, Hexagon } from 'lucide-react' +import { Search, Loader2, MapPin, Hexagon, Check } from 'lucide-react' import HexBoundaryPreview from './HexBoundaryPreview' -import { resolveContainingCell } from '@/lib/hex-lookup' +import { resolveContainingCell, neighborCells } from '@/lib/hex-lookup' +import { cellStatusCta, type CellStatusResult } from '@/lib/cell-status' import { parseForwardSuggestions, type AddressSuggestion } from '@/lib/mapbox-geocode' +import { formatGenesisListingUsd } from '@/lib/h3' -type Selected = { - hexId: string - label: string - sublabel: string -} +type Selected = { hexId: string; label: string; sublabel: string; lat: number; lng: number } /** - * Address → Hex lookup (Slice 1, read-only). - * Type an address, pick a suggestion, and see the Res-4 H3 cell that contains it. - * Reservation / status CTAs land in later slices. + * Address → Hex lookup & reserve (Slices 1–4). + * Type an address, see the Res-4 cell that contains it, and act on its status: + * mint a curated cell, place a global hold, or pick an available neighbour. */ export default function AddressHexLookup({ className = '' }: { className?: string }) { const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN @@ -23,10 +21,17 @@ export default function AddressHexLookup({ className = '' }: { className?: strin const [query, setQuery] = useState('') const [suggestions, setSuggestions] = useState([]) const [loading, setLoading] = useState(false) - const [selected, setSelected] = useState(null) const [open, setOpen] = useState(false) const justSelected = useRef(false) + const [selected, setSelected] = useState(null) + const [status, setStatus] = useState(null) + const [statusLoading, setStatusLoading] = useState(false) + const [nearby, setNearby] = useState([]) + + const [holdEmail, setHoldEmail] = useState('') + const [holdState, setHoldState] = useState<'idle' | 'submitting' | 'error'>('idle') + // Debounced forward geocode (Mapbox Search API v6 forward, autocomplete). useEffect(() => { if (!token) return @@ -39,17 +44,11 @@ export default function AddressHexLookup({ className = '' }: { className?: strin setSuggestions([]) return } - let cancelled = false setLoading(true) const handle = setTimeout(async () => { try { - const params = new URLSearchParams({ - q, - autocomplete: 'true', - limit: '5', - access_token: token, - }) + const params = new URLSearchParams({ q, autocomplete: 'true', limit: '5', access_token: token }) const res = await fetch(`https://api.mapbox.com/search/geocode/v6/forward?${params}`) const data = await res.json() if (cancelled) return @@ -61,33 +60,76 @@ export default function AddressHexLookup({ className = '' }: { className?: strin if (!cancelled) setLoading(false) } }, 300) - return () => { cancelled = true clearTimeout(handle) } }, [query, token]) - function pick(s: AddressSuggestion) { - const hexId = resolveContainingCell(s.lat, s.lng) + // Resolve the selected cell's status (and refetch after a hold). + async function loadStatus(sel: Selected) { + setStatusLoading(true) + setNearby([]) + try { + const res = await fetch(`/api/hexes/resolve?hex=${sel.hexId}`) + const data: CellStatusResult = await res.json() + setStatus(data) + // For taken / native cells, surface available neighbours. + if (data.status === 'curated-taken' || data.status === 'global-held' || data.status === 'native') { + const ring = neighborCells(sel.hexId) + const resolved = await Promise.all( + ring.map((id) => fetch(`/api/hexes/resolve?hex=${id}`).then((r) => r.json() as Promise)), + ) + setNearby(resolved.filter((c) => c.status === 'curated-available' || c.status === 'global-available')) + } + } finally { + setStatusLoading(false) + } + } + + function selectCell(sel: Selected) { justSelected.current = true - setSelected({ hexId, label: s.label, sublabel: s.sublabel }) - setQuery(s.label) + setSelected(sel) + setStatus(null) + setHoldState('idle') + setQuery(sel.label) setSuggestions([]) setOpen(false) + void loadStatus(sel) + } + + function pick(s: AddressSuggestion) { + selectCell({ hexId: resolveContainingCell(s.lat, s.lng), label: s.label, sublabel: s.sublabel, lat: s.lat, lng: s.lng }) + } + + async function placeHold() { + if (!selected || !holdEmail.trim()) return + setHoldState('submitting') + try { + const res = await fetch('/api/holds', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: holdEmail.trim(), lat: selected.lat, lng: selected.lng }), + }) + if (!res.ok) throw new Error() + await loadStatus(selected) // now global-held + setHoldState('idle') + } catch { + setHoldState('error') + } } if (!token) { return ( -
+
Add NEXT_PUBLIC_MAPBOX_TOKEN to enable address lookup.
) } + const cta = status ? cellStatusCta(status.status) : null + return (
{open && suggestions.length > 0 && ( -
    +
      {suggestions.map((s) => (
    • -
    • @@ -138,14 +171,87 @@ export default function AddressHexLookup({ className = '' }: { className?: strin {selected && (
      -
      - -
      -

      {selected.label} sits in this Res-4 hex:

      -

      {selected.hexId}

      +
      +
      + +
      +

      {selected.label}

      +

      {selected.hexId}

      +
      + {status && ( + {formatGenesisListingUsd(status.priceUsd)} + )}
      + + + {/* Status-driven CTA */} +
      + {statusLoading || !cta ? ( +

      + Checking availability… +

      + ) : cta.action === 'mint' ? ( + + {cta.label} + + ) : cta.action === 'hold' ? ( +
      +

      + This cell is outside the Genesis 200. Reserve it with a free 30-day hold — you’ll get first claim when its round opens. +

      +
      + setHoldEmail(e.target.value)} + placeholder="you@email.com" + className="w-full rounded-lg border border-gray-700 bg-[#0a121f] px-3 py-2 text-sm text-white placeholder-gray-600 outline-none focus:border-malama-teal" + /> + +
      + {holdState === 'error' &&

      Couldn’t place the hold — try again.

      } +
      + ) : ( +
      + {status?.status === 'global-held' ? ( + {cta.label} + ) : ( + cta.label + )} +
      + )} +
      + + {/* Nearby available cells when this one is taken */} + {nearby.length > 0 && ( +
      +

      Available nearby:

      +
      + {nearby.map((n) => ( + + ))} +
      +
      + )}
      )}
      diff --git a/apps/web/src/lib/cell-status.test.ts b/apps/web/src/lib/cell-status.test.ts index cf521da..9084248 100644 --- a/apps/web/src/lib/cell-status.test.ts +++ b/apps/web/src/lib/cell-status.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { classifyCellStatus, resolveCellStatus } from './cell-status' +import { classifyCellStatus, resolveCellStatus, cellStatusCta } from './cell-status' describe('classifyCellStatus', () => { it('marks an unclaimed curated cell as curated-available', () => { @@ -31,3 +31,29 @@ describe('classifyCellStatus', () => { await expect(resolveCellStatus('not-a-hex')).rejects.toThrow() }) }) + +describe('cellStatusCta', () => { + it('offers minting for an available curated cell', () => { + const cta = cellStatusCta('curated-available') + expect(cta.action).toBe('mint') + expect(cta.enabled).toBe(true) + }) + + it('offers a hold for an available global cell', () => { + const cta = cellStatusCta('global-available') + expect(cta.action).toBe('hold') + expect(cta.enabled).toBe(true) + }) + + it('disables taken and held cells', () => { + expect(cellStatusCta('curated-taken').enabled).toBe(false) + expect(cellStatusCta('curated-taken').action).toBe('none') + expect(cellStatusCta('global-held').enabled).toBe(false) + }) + + it('disables native cells with a tribal-reservation label', () => { + const cta = cellStatusCta('native') + expect(cta.enabled).toBe(false) + expect(cta.label).toMatch(/tribe|native|reserved/i) + }) +}) diff --git a/apps/web/src/lib/cell-status.ts b/apps/web/src/lib/cell-status.ts index dec8c8f..8b88198 100644 --- a/apps/web/src/lib/cell-status.ts +++ b/apps/web/src/lib/cell-status.ts @@ -31,6 +31,29 @@ export function classifyCellStatus(facts: { return facts.isHeld ? 'global-held' : 'global-available' } +export type CellCta = { + label: string + action: 'mint' | 'hold' | 'none' + enabled: boolean + tone: 'primary' | 'muted' +} + +/** Map a cell status to the call-to-action the lookup UI should render. */ +export function cellStatusCta(status: CellStatus): CellCta { + switch (status) { + case 'curated-available': + return { label: 'Reserve & mint this hex', action: 'mint', enabled: true, tone: 'primary' } + case 'global-available': + return { label: 'Reserve this cell (30-day hold)', action: 'hold', enabled: true, tone: 'primary' } + case 'curated-taken': + return { label: 'Already claimed', action: 'none', enabled: false, tone: 'muted' } + case 'global-held': + return { label: 'Already reserved', action: 'none', enabled: false, tone: 'muted' } + case 'native': + return { label: 'Reserved for Native Tribes', action: 'none', enabled: false, tone: 'muted' } + } +} + // Built once at module load — the curated 200 and native sets are static data. const CURATED_HEX_SET = new Set( Object.values(regionsData as Record).flatMap((r) => r?.cells ?? []), diff --git a/apps/web/src/lib/hex-lookup.test.ts b/apps/web/src/lib/hex-lookup.test.ts index 8f6f59f..fb0d45d 100644 --- a/apps/web/src/lib/hex-lookup.test.ts +++ b/apps/web/src/lib/hex-lookup.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { getResolution } from 'h3-js' -import { resolveContainingCell } from './hex-lookup' +import { resolveContainingCell, neighborCells } from './hex-lookup' describe('resolveContainingCell', () => { it('returns the Res-4 cell that contains a coordinate', () => { @@ -28,3 +28,14 @@ describe('resolveContainingCell', () => { expect(distant).not.toBe(cityHall) }) }) + +describe('neighborCells', () => { + it('returns the surrounding cells, excluding the cell itself', () => { + const cell = '8429a1dffffffff' + const neighbors = neighborCells(cell) + + expect(neighbors).toHaveLength(6) + expect(neighbors).not.toContain(cell) + expect(neighbors.every((id) => getResolution(id) === 4)).toBe(true) + }) +}) diff --git a/apps/web/src/lib/hex-lookup.ts b/apps/web/src/lib/hex-lookup.ts index a3d5493..2d9efe0 100644 --- a/apps/web/src/lib/hex-lookup.ts +++ b/apps/web/src/lib/hex-lookup.ts @@ -1,4 +1,4 @@ -import { latLngToCell } from 'h3-js' +import { latLngToCell, gridDisk } from 'h3-js' /** The H3 resolution sold in the current funding round (~1,770 km² per cell). */ export const RES4 = 4 @@ -7,3 +7,8 @@ export const RES4 = 4 export function resolveContainingCell(lat: number, lng: number): string { return latLngToCell(lat, lng, RES4) } + +/** The ring of cells immediately surrounding a cell (excludes the cell itself). */ +export function neighborCells(hexId: string): string[] { + return gridDisk(hexId, 1).filter((id) => id !== hexId) +} From 354d22b3ed461b139393bfe37b68c399ab1a3f79 Mon Sep 17 00:00:00 2001 From: tyler malin Date: Tue, 23 Jun 2026 08:19:13 -0700 Subject: [PATCH 6/7] feat(web): render active global holds on the map (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listActiveHolds(): all active holds from hold:index (expired filtered). - GET /api/hexes/holds: active holds as a GeoJSON FeatureCollection. - HexMap: amber dashed overlay layer for held cells, visually distinct from the teal Genesis grid. Slice 5 of address-hex-lookup. 25 tests green incl. an in-process route-handler test (live dev can't persist memKv across requests without Redis — same limitation as the existing claim flow; works in production/preview where Upstash is connected). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web/src/app/api/hexes/holds/route.test.ts | 25 ++++++++++++++++ apps/web/src/app/api/hexes/holds/route.ts | 18 ++++++++++++ apps/web/src/components/HexMap.tsx | 29 +++++++++++++++++++ apps/web/src/lib/global-hold-store.test.ts | 13 ++++++++- apps/web/src/lib/global-hold-store.ts | 7 +++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/api/hexes/holds/route.test.ts create mode 100644 apps/web/src/app/api/hexes/holds/route.ts diff --git a/apps/web/src/app/api/hexes/holds/route.test.ts b/apps/web/src/app/api/hexes/holds/route.test.ts new file mode 100644 index 0000000..69a6f66 --- /dev/null +++ b/apps/web/src/app/api/hexes/holds/route.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { GET } from './route' +import { issueGlobalHold } from '@/lib/global-hold-store' + +// In-process test — a single Vitest process shares one memKv, so this exercises the +// real route handler end-to-end (live dev can't, because memKv doesn't persist across +// Next's per-request module boundaries without Redis configured). +describe('GET /api/hexes/holds', () => { + it('serves active holds as a GeoJSON FeatureCollection', async () => { + const SYD = '84be0e3ffffffff' + await issueGlobalHold({ hexId: SYD, email: 'overlay@example.com', lat: -33.87, lng: 151.21 }) + + const res = await GET() + const body = (await res.json()) as { + type: string + features: Array<{ geometry: { type: string }; properties: { id: string; status: string } }> + } + + expect(body.type).toBe('FeatureCollection') + const feature = body.features.find((f) => f.properties.id === SYD) + expect(feature).toBeDefined() + expect(feature?.geometry.type).toBe('Polygon') + expect(feature?.properties.status).toBe('held') + }) +}) diff --git a/apps/web/src/app/api/hexes/holds/route.ts b/apps/web/src/app/api/hexes/holds/route.ts new file mode 100644 index 0000000..5d66807 --- /dev/null +++ b/apps/web/src/app/api/hexes/holds/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' +import { listActiveHolds } from '@/lib/global-hold-store' +import { hexToGeoJSON } from '@/lib/h3' + +export const dynamic = 'force-dynamic' + +/** + * Active global holds as a GeoJSON FeatureCollection, for the map overlay. + * These are off-chain reservations of Res-4 cells outside the curated 200. + */ +export async function GET() { + const holds = await listActiveHolds() + const features = holds.map((h) => ({ + ...hexToGeoJSON(h.hexId), + properties: { id: h.hexId, status: 'held', heldAt: h.heldAt, expiresAt: h.expiresAt }, + })) + return NextResponse.json({ type: 'FeatureCollection', features }) +} diff --git a/apps/web/src/components/HexMap.tsx b/apps/web/src/components/HexMap.tsx index 7472165..e1073a3 100644 --- a/apps/web/src/components/HexMap.tsx +++ b/apps/web/src/components/HexMap.tsx @@ -166,6 +166,35 @@ export default function HexMap() { filter: ['==', 'id', ''] }) + // Global holds overlay — cells reserved off-chain outside the curated 200. + // Rendered amber + dashed to read clearly apart from the teal Genesis grid. + try { + const holdsRes = await fetch('/api/hexes/holds') + const holdsData = await holdsRes.json() + if (Array.isArray(holdsData.features) && holdsData.features.length > 0) { + m.addSource('holds', { type: 'geojson', data: holdsData }) + m.addLayer({ + id: 'holds-fill', + type: 'fill', + source: 'holds', + paint: { 'fill-color': '#F59E0B', 'fill-opacity': 0.18 }, + }) + m.addLayer({ + id: 'holds-line', + type: 'line', + source: 'holds', + paint: { + 'line-color': '#F59E0B', + 'line-width': 1.5, + 'line-dasharray': [2, 1.5], + 'line-opacity': 0.9, + }, + }) + } + } catch (e) { + console.warn('Failed to load holds overlay', e) + } + const animatePulse = () => { if (!m.isStyleLoaded()) { requestAnimationFrame(animatePulse) diff --git a/apps/web/src/lib/global-hold-store.test.ts b/apps/web/src/lib/global-hold-store.test.ts index 3ee1e3c..52ca678 100644 --- a/apps/web/src/lib/global-hold-store.test.ts +++ b/apps/web/src/lib/global-hold-store.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { issueGlobalHold, getGlobalHold, releaseGlobalHold, listHoldsByEmail } from './global-hold-store' +import { issueGlobalHold, getGlobalHold, releaseGlobalHold, listHoldsByEmail, listActiveHolds } from './global-hold-store' import { getStats } from './genesis-claim-registry' // Distinct global Res-4 cells per test — memKv persists in-process between tests, @@ -82,4 +82,15 @@ describe('global-hold-store', () => { expect(holds.map((h) => h.hexId).sort()).toEqual([SF, TYO].sort()) expect(holds.every((h) => h.email === 'multi@example.com')).toBe(true) }) + + it('lists all active holds across users (for the map overlay)', async () => { + const SYD = '84be0e3ffffffff' + const CAI = '843e629ffffffff' + await issueGlobalHold({ hexId: SYD, email: 'au@example.com', lat: -33.87, lng: 151.21 }) + await issueGlobalHold({ hexId: CAI, email: 'eg@example.com', lat: 30.04, lng: 31.24 }) + + const ids = (await listActiveHolds()).map((h) => h.hexId) + expect(ids).toContain(SYD) + expect(ids).toContain(CAI) + }) }) diff --git a/apps/web/src/lib/global-hold-store.ts b/apps/web/src/lib/global-hold-store.ts index 4aa2859..5493802 100644 --- a/apps/web/src/lib/global-hold-store.ts +++ b/apps/web/src/lib/global-hold-store.ts @@ -101,6 +101,13 @@ export async function listHoldsByEmail(email: string, opts?: ClockOpts): Promise return holds.filter((h): h is GlobalHold => h !== null) } +/** Every currently-active hold across all users (for the map overlay). */ +export async function listActiveHolds(opts?: ClockOpts): Promise { + const ids = await kv.smembers(K.index) + const holds = await Promise.all(ids.map((id) => getGlobalHold(id, opts))) + return holds.filter((h): h is GlobalHold => h !== null) +} + /** Release a hold. Only the owning email may release it. */ export async function releaseGlobalHold( hexId: string, From dca4f302e8e45d2cb0108f747700bd400a36c54f Mon Sep 17 00:00:00 2001 From: tyler malin Date: Tue, 23 Jun 2026 08:26:51 -0700 Subject: [PATCH 7/7] feat(web): holds overlay on /explorer map + fix stale branch docs - explorer HexMap: amber dashed global-holds overlay (HOLDS_SOURCE), matching /map. - CLAUDE.md: branch strategy now reflects 'ready-to-go' as the integration branch (develop does not exist); flag the CI/deploy-testnet 'develop' trigger divergence for the team to reconcile. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 +++++-- apps/web/src/explorer/components/HexMap.tsx | 35 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 580fec7..76696d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,13 +65,18 @@ Required keys: MAPBOX_TOKEN, PINATA_API_KEY, PINATA_API_SECRET, KAFKA_BOOTSTRAP, ## Branch Strategy - `main` — production-ready code -- `develop` — integration branch -- Feature branches off `develop`, PRs back to `develop` +- `ready-to-go` — current integration branch where active work lands (ahead of `main`) +- Feature branches off `ready-to-go`, PRs back to `ready-to-go` + +> ⚠️ Divergence to reconcile: CI (`ci.yml`) and testnet deploy (`deploy-testnet.yml`) +> still trigger on a `develop` branch that does not currently exist on the remote. +> Either create `develop` as the integration branch or repoint those workflows at +> `ready-to-go`. ## CI/CD - GitHub Actions: Aiken build/test, Hardhat compile/test/coverage, Python pytest, Node typecheck/lint/build -- Testnet deploy: push to `develop` triggers Cardano Pre-Prod + Base Sepolia deployment +- Testnet deploy: push to `develop` triggers Cardano Pre-Prod + Base Sepolia deployment (see divergence note above — `develop` is currently absent) - Vercel: `apps/web` auto-deploys from `main` ## Conventions diff --git a/apps/web/src/explorer/components/HexMap.tsx b/apps/web/src/explorer/components/HexMap.tsx index 5afe3df..f05ae6e 100644 --- a/apps/web/src/explorer/components/HexMap.tsx +++ b/apps/web/src/explorer/components/HexMap.tsx @@ -65,6 +65,7 @@ export interface HexMapHandle { const PHASE1_SOURCE = 'phase1-hexes'; const LAND_SOURCE = 'land-hexes'; const CONTEXT_SOURCE = 'context-hexes'; +const HOLDS_SOURCE = 'global-holds'; /** * Zoom level at which the viewport-driven context hex grid becomes @@ -389,6 +390,40 @@ export const HexMap = forwardRef(function HexMap( m.getCanvas().style.cursor = ''; }); } + + // Global holds overlay — off-chain reservations of cells outside the + // curated set. Amber dashed, matching the /map overlay. Added once, then + // populated from the holds GeoJSON endpoint. + if (!m.getSource(HOLDS_SOURCE)) { + m.addSource(HOLDS_SOURCE, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }); + m.addLayer({ + id: 'holds-fill', + type: 'fill', + source: HOLDS_SOURCE, + paint: { 'fill-color': '#F59E0B', 'fill-opacity': 0.18 }, + }); + m.addLayer({ + id: 'holds-line', + type: 'line', + source: HOLDS_SOURCE, + paint: { + 'line-color': '#F59E0B', + 'line-width': 1.5, + 'line-dasharray': [2, 1.5], + 'line-opacity': 0.9, + }, + }); + fetch('/api/hexes/holds') + .then((r) => r.json()) + .then((data) => { + const src = m.getSource(HOLDS_SOURCE) as mapboxgl.GeoJSONSource | undefined; + if (src && Array.isArray(data.features)) src.setData(data); + }) + .catch((e) => console.warn('Failed to load holds overlay', e)); + } } }, [currentResolution, landCells, manifest, onHexClick]);