commit 9706fcc520672f5b9788f627789471ea094e2126 Author: abhishek Date: Mon Sep 28 17:24:00 2026 +0530 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4319b16 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# What must never enter the build context. +# +# There was no .dockerignore at all, so every one of these was being uploaded to +# the Docker daemon on each build and then copied into the builder image by +# `COPY . .`. +# +# `.git` is the one that matters for speed — it carries the entire history, and +# it grows forever while being worthless to a build. The rest is correctness: +# `dist` and `node_modules` from a developer's machine would be copied in and +# then overwritten by the build, but only after being transferred, and a stale +# `dist` copied over the fresh one is a genuinely confusing failure. +.git +.gitignore +node_modules +dist + +# Transfer archives. The scratch files these rules were written for — the +# `_to_delete` directory, `_inv.txt`, and two empty archives — are gone from the +# repository; the glob stays so the next one never reaches a build context. +*.zip +*.tgz + +# Per-machine overrides. `.env` itself IS wanted — it carries VITE_API_BASE and +# the build needs it — but `.env.local` is a developer's private override and +# must not decide what production talks to. +.env.local +*.local + +.vscode +.DS_Store +README.md diff --git a/.env b/.env new file mode 100644 index 0000000..6848860 --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +# The Fiesta API host. Read by Vite at build time and compiled into the bundle. +# +# This is the single source of truth for where the console talks to the backend, +# and it is not a secret — it is the same public host the customer app calls. +# +# Committed on purpose: a deployed build has to carry it, and a value that lives +# only on one developer's machine is one the build server does not have. Vite +# only exposes `VITE_`-prefixed names to client code, so nothing else in here +# would reach the browser. +# +# `.env.local` overrides this and is gitignored — that is the file to use for a +# staging backend, or `/fiesta` to route through the dev proxy instead. +VITE_API_BASE="https://fiesta.nearle.app" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd616c6 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# The Fiesta API host used by the production build. +# In development this is left unset and Vite proxies /fiesta -> fiesta.nearle.app +# (see vite.config.ts), which keeps the network tab consistent and avoids +# preflight surprises. +VITE_API_BASE="https://fiesta.nearle.app" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c6e3e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules +dist + +*.local +.DS_Store +.vscode + +# Transfer archives. Nothing of the sort belongs in the repository — the two +# that were here (`_sync.zip`, `_their-src.tgz`) had been committed empty and +# stayed for weeks. +*.zip +*.tgz + +# Build output from scripts/mapPreview.mjs — a local viewer, never committed. +scripts/.preview/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c0c80ba --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..be0e870 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,131 @@ +# Stage 1 — build +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ + +# `npm ci`, with no `|| npm install` fallback. +# +# That fallback was here and it is worse than the error it hides. `npm ci` +# fails only when package-lock.json disagrees with package.json — exactly the +# case where falling back to `npm install` resolves fresh versions the lock file +# never pinned, so the deployed bundle is built from dependencies nobody chose +# and nobody can reproduce. +# +# When this line fails, the fix is to update package-lock.json locally and +# COMMIT it. The build should not paper over a lock file that is out of date; +# it should say so. +# +# ── But `npm install` alone is how the lock got broken once ───────────────── +# +# This image is node:22-alpine, which ships npm 10.9.8. A developer on npm 11 +# running `npm install` rewrites the lock in a shape npm 10 rejects: npm 11 +# prunes optional platform packages that npm 10 still validates. Adding leaflet +# on npm 11.6.2 dropped `@emnapi/core` and `@emnapi/runtime` — transitive +# optional deps of `@tailwindcss/oxide-wasm32-wasi` — and the next deploy died +# here with "Missing: @emnapi/core@1.11.3 from lock file". Nothing was wrong +# with the code; the lock was genuinely incomplete and this line was right to +# refuse it. +# +# So after changing dependencies, prove the lock against THIS npm before +# pushing, in a scratch directory so node_modules is not disturbed: +# +# mkdir /tmp/lockcheck && cp package.json package-lock.json /tmp/lockcheck/ +# cd /tmp/lockcheck && npx npm@10.9.8 ci +# +# and if it fails, regenerate with the same version: +# +# npx npm@10.9.8 install --package-lock-only +RUN npm ci --no-audit --no-fund + +COPY . . + +# Where the bundle points at the backend, fixed HERE rather than left to `.env`. +# +# Deployment platforms write their own `.env` into the source directory before +# building — Dokploy does — which overwrites the committed one and takes +# VITE_API_BASE with it. The build then fell through to a same-origin path and +# the console called `https:///fiesta/live/api/...`. A build +# argument outranks the file, so the host survives that overwrite. +# +# Override per environment with `--build-arg VITE_API_BASE=...` (Dokploy: Build +# Args), e.g. to point a staging console at a staging Fiesta. +ARG VITE_API_BASE="https://fiesta.nearle.app" +ENV VITE_API_BASE=$VITE_API_BASE + +# There is no workspace flag in this image. +# +# This repository IS the Nearle platform console — every route it mounts is the +# platform workspace and only Nearle staff can sign in. The merchant console is +# a separate repository with its own deploy. A flag here would exist only as a +# way to deploy this application as something it is not. +# +# The merchant console's address, for the sentence shown to a merchant who +# signs in at the wrong site. A build argument rather than a constant because +# the two are separate deployments and either can move. +ARG VITE_MERCHANT_HOST="app.nearledaily.com" +ENV VITE_MERCHANT_HOST=$VITE_MERCHANT_HOST + +RUN npm run build + +# Stage 2 — serve +FROM nginx:alpine + +# The config is a TEMPLATE, and that is deliberate. +# +# nginx:alpine's entrypoint runs `envsubst` over /etc/nginx/templates/*.template +# at container start and writes the result into conf.d. That is how the ingest +# API key reaches nginx as a runtime environment variable rather than being +# committed here in plain text — which is what the previous Dockerfile did with +# the Hasura secret, on the line this replaces. +COPY nginx.conf.template /etc/nginx/templates/default.conf.template + +# Restricts substitution to this one name. +# +# Without the filter, envsubst replaces every `${...}` it recognises as an +# environment variable — and the container's environment carries HOSTNAME, PATH +# and friends. Nginx's own `$uri`, `$remote_addr` and `$proxy_add_x_forwarded_for` +# would survive that today, but only by luck, and a config silently rewritten at +# boot is a bad thing to leave to luck. +ENV NGINX_ENVSUBST_FILTER="(INGEST_TOKEN|INGEST_UPSTREAM)" + +# Empty by default — and this line is LOAD-BEARING. Do not delete it. +# +# BuildKit warns about it: `SecretsUsedInArgOrEnv: Do not use ARG or ENV +# instructions for sensitive data (ENV "INGEST_TOKEN")`. The warning is right in +# general and wrong here: nothing sensitive is baked in, the value is the empty +# string, and the real token is supplied at RUN time by the deployment. +# +# Removing the line to silence the warning breaks the config in a way that is +# hard to see. nginx's entrypoint builds its substitution list from env vars +# that are DEFINED: +# +# defined_envs=$(printf '${%s} ' $(awk "END { for (name in ENVIRON) ... }")) +# +# With INGEST_TOKEN undefined, it is not in that list, envsubst leaves the +# placeholder alone, and nginx ends up with the literal text `${INGEST_TOKEN}` +# as the token — which is not empty, so the missing-token guard never fires and +# every ingest call goes out with a nonsense `X-API-Key`. +# +# Declaring it empty here guarantees envsubst always substitutes it, so an +# unset token is a real empty string and the guard can catch it. +ENV INGEST_TOKEN="" + +# Where the ingest service is. Declared here for the same reason as the line +# above: envsubst only substitutes names that are DEFINED, so an undeclared +# INGEST_UPSTREAM would leave the literal text `${INGEST_UPSTREAM}` in the +# config as the proxy target, and nginx would fail to start with an error that +# names the variable rather than the omission. +# +# Defaults to the public host so an existing deployment behaves exactly as it +# did. Set it to the sibling container's internal address — e.g. +# `http://mcp-backend:8000` — to take the private path and drop the credential +# entirely. +ENV INGEST_UPSTREAM="https://mcp.nearle.ai.in" + +COPY --from=builder /app/dist/ /usr/share/nginx/html/ + +EXPOSE 80 3000 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f267045 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# Nearle Platform Console + +Nearle's own console, used by Nearle staff at **platform.nearledaily.com**: +the tenant directory, onboarding, the global catalogue, delivery partners and +platform-wide dispatch. Built against the existing Fiesta backend — the API is a +fixed constraint, not something this repo changes. + +## This is not the merchant console + +Merchants and their branch users sign in at **app.nearledaily.com**, which is a +separate repository (`nearle-console`) with its own deploy. Only `nearle-admin` +accounts can sign in here; a merchant account is refused with a sentence naming +where it belongs, and the refusal happens before any session is written. + +The two were one application with three workspaces behind a role guard, and were +split so an internal tool and a customer-facing product could move at their own +pace — and so a change made for staff could not reach a shop. + +**They share no code at runtime, and roughly thirty thousand lines are duplicated +between them**: the API layer, the query cache, the component library, the +drawers. That is a deliberate trade, taken because this side is internal — a +divergence here is something the team notices in its own tool rather than +something a merchant discovers. A fix worth having in both has to be made twice, +on purpose. + +Folders named `store-admin` remain under `src/features/`. They are not merchant +screens: they are the shared pieces this console depends on — the dispatch board, +the drawer kit, formatting, assignment logic — still carrying the name they had +before the split. + +## Running it + +```bash +npm install +npm run dev # http://localhost:3100 +npm run typecheck # tsc --noEmit +npm run build # typecheck + production build +``` + +> **Port 3100, not 3000.** The old console (`daily_merchant_web`) runs its dev +> server on 3000. `strictPort` is on, so if 3100 is taken this fails loudly +> rather than silently moving — which is the failure that makes you think your +> changes did not land when you are actually looking at a different app. + +In development Vite proxies `/fiesta` → `https://fiesta.nearle.app`. For a +deployed build set `VITE_API_BASE` (see `.env.example`). + +## Stack + +| Layer | Choice | +|---|---| +| Build | Vite 8 | +| Language | **TypeScript 7**, strict, `noUncheckedIndexedAccess`. No `.js`/`.jsx` anywhere. | +| UI | React 19 + **@astryxdesign/core** | +| Theme | `src/theme/nearle.ts`, compiled to `nearle.css` | +| Routing | React Router 7, split per page | +| Server state | TanStack Query 5 | +| Icons | lucide-react | +| Spreadsheets | `xlsx`, loaded on demand | + +## What's built + +All three workspaces. Which one opens is decided by the account, not chosen — +see `src/auth/roles.ts`. + +**Nearle Admin** (`issuperadmin`) — the platform operator: + +- `/nearle/stores` — every tenant, branch counts, per-tenant performance +- `/nearle/stores/:tenantId` — one tenant's branches, orders and revenue +- `/nearle/onboard/tenant` — provision a merchant group and its first outlet +- `/nearle/catalogue` — the global catalogue, plus both product-import paths +- `/nearle/partners` — rider partners, and the riders under each +- `/nearle/dispatch` — every partner's live work and shifts +- `/nearle/uploads` — spreadsheets sent to the catalogue service + +**Store Admin** (roleid 1 and 3) — one merchant, every branch: + +- `/admin/console` — online and counter sales side by side, per branch +- `/admin/sales` · `/admin/dispatch` · `/admin/inventory` · `/admin/reports` +- `/admin/branches/new` — commission an outlet with its delivery thresholds +- `/admin/users` — back-office people and till accounts +- `/admin/terminals` · `/admin/uploads` · `/admin/profile` · `/admin/onboarding` + +**Store user** (everything else) — one branch, scoped to it: + +- `/store/console` · `/store/products` · `/store/sales` · `/store/dispatch` +- `/store/reports` · `/store/customers` · `/store/terminals` · `/store/staff` +- `/store/uploads` · `/store/account` · `/store/setup` + +Two routes are redirects rather than pages, and deliberately: +`/nearle/onboard/branch` → `/nearle/stores` (a branch is commissioned from the +tenant that will own it), and `/nearle/fleet` → `/nearle/dispatch`. + +## Things about the backend that shape this code + +These are not bugs in this repo. They are the API's behaviour, and each one is +worked around deliberately. + +1. **No web authentication.** The login endpoints return the user record and no + token; only `/v1/pos/*` has middleware. So the session *is* that record, held + in `sessionStorage`. `src/auth/session.ts` is the only file that changes when + tokens arrive. +2. **Every list call needs a scoping id or it 400s.** The IDOR pass added + controller-level guards. Query hooks are therefore `enabled`-gated on the id + and the id is part of the query key. +3. **Roles are derived, not asserted.** `issuperadmin` is checked before + `roleid`, because the flag is server-derived and a roleid is not. Roleids 7 + and 8 are till roles and never reach an admin workspace. +4. **`POST /products/create` takes one product and returns no id.** The + controller passes the struct to the service by value, so GORM writes the + generated id into a copy that is then discarded. The sheet importer therefore + creates, then re-queries by SKU to resolve ids, then batches the location and + stock writes. See `importSheetProducts` in `src/api/products.ts`. +5. **The two import paths are not symmetric.** Catalogue import is one + idempotent batch call. Sheet import is N creates with no dedupe on SKU, so + re-uploading a file duplicates its products — the UI says so before the + button. +6. **The global catalogue has a price *range*, not a price**, and no mapping to + a tenant's own categories. Category, subcategory, retail price, cost and tax + are collected before an import can be enabled. +7. **POS endpoints take one required `locationid`.** There is no tenant-wide + counter-sales call, so a multi-branch POS view fans out per branch. + +## The 30-second cadence + +Online orders land when an order is placed; counter sales reach the console on a +30-second refetch; anything still sitting on an offline till has not arrived at +all. So **any figure blending the two is eventually consistent**, and the +`` component exists to say when a number was last true and how many +bills are still stranded. Polling is applied per query in `src/queries/hooks.ts` +rather than globally — a provisioning form has no business re-polling. + +## Design + +The visual system is ported from KROW (which is built on Astryx too, so the +tokens are role-for-role comparable), with Nearle purple `#662582` as the accent. +The ambient canvas in `index.css` — a fixed, viewport-wide horizontal gradient at +`z-index: -1` — is the one piece of KROW that carries most of the character. + +Radius, type scale and control heights are matched to KROW in +`src/theme/nearle.ts`, with the reasoning kept in comments. + +**Open:** the dark-mode accent `#B57FD0` was chosen for 5.41:1 contrast on +Astryx's dark surface and still needs brand-owner sign-off before dark mode +ships. diff --git a/index.html b/index.html new file mode 100644 index 0000000..5c6b8b7 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Nearle Console + + +
+ + + diff --git a/nginx.conf.template b/nginx.conf.template new file mode 100644 index 0000000..9594d87 --- /dev/null +++ b/nginx.conf.template @@ -0,0 +1,183 @@ +# Nginx for the deployed console. +# +# A `.template`, not a plain conf: the nginx:alpine entrypoint runs `envsubst` +# over everything in /etc/nginx/templates and writes the result into conf.d at +# container start. That is what lets the ingest API key arrive as a runtime +# environment variable instead of being committed to this repository. +# +# ── Why this file exists in this shape ─────────────────────────────────────── +# +# The previous version was inherited from the old console and proxied `/hasura/` +# — a path this console never calls — while having no block for `/fiesta/` at +# all. Every API call therefore fell through to `try_files … /index.html`, and +# nginx answers a POST to a static file with **405 Method Not Allowed** and an +# HTML body. The console reported "Malformed response (HTTP 405)", which was +# accurate and pointed nowhere near the cause: sign-in was never reaching the +# backend. +# +# The rule this file follows: every prefix the Vite dev server proxies must have +# a matching block here. `vite.config.ts` is the other half of this file, and +# the two drift apart silently — it works on every developer machine and fails +# only once deployed. + +server { + listen 80; + listen 3000; + server_name _; + + # 10 MB is the ingest service's own file limit, so anything larger is going + # to be refused anyway — but nginx's default is 1 MB, and it rejects the + # upload itself with a 413 before the request ever leaves this container. + # A merchant's product sheet passes 1 MB easily. + client_max_body_size 12m; + + # ── The app ────────────────────────────────────────────────────────────── + location / { + root /usr/share/nginx/html; + index index.html; + # React Router owns the paths, so an unknown one is a route, not a 404. + try_files $uri $uri/ /index.html; + + # index.html must be revalidated on every visit, and until now it was + # not — the line below is new, and its absence was a real outage. + # + # The block above said "index.html must NOT be cached" and then set no + # cache header at all, which is not the same thing. With neither + # `Cache-Control` nor `Expires`, a browser falls back to HEURISTIC + # caching: RFC 9111 lets it invent a freshness lifetime from + # `Last-Modified`, commonly a tenth of the document's age, and serve the + # document from disk WITHOUT revalidating. So a tab kept the previous + # index.html, that index.html named `InventoryPage-BAzj-ICF.js`, the + # deploy had replaced it with `InventoryPage-Cbh53mHU.js`, and the + # import 404'd on a screen that had worked ten minutes earlier. + # + # `no-cache` does NOT mean "do not store" — it means "revalidate before + # use". The ETag still answers 304 on an unchanged deploy, so this costs + # one conditional request per visit and never a re-download. + add_header Cache-Control "no-cache" always; + } + + # Hashed filenames, so these can be cached hard — the hash changes when the + # content does, which is what makes a year safe. + location /assets/ { + root /usr/share/nginx/html; + + # ONE header, not two. `expires 1y` emits its own + # `Cache-Control: max-age=31536000`, and the `add_header` beside it + # appended a second, so every asset went out with two conflicting + # `Cache-Control` lines — `max-age=31536000` and `public, immutable`, + # neither complete. Browsers mostly cope; caches and CDNs in between are + # entitled to take the first and drop `immutable`, or to treat the pair + # as malformed. Merged into a single directive, with `expires` dropped + # because it exists only to emit the header this now sets by hand. + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + # ── Fiesta ─────────────────────────────────────────────────────────────── + # + # Mirrors the dev proxy exactly: `/fiesta/live/api/...` → `/live/api/...` on + # fiesta.nearle.app. The trailing slash on proxy_pass is what strips the + # prefix — without it the upstream receives `/fiesta/live/...` and 404s. + # + # Proxied rather than called directly from the browser so the API stays + # same-origin. That keeps CORS out of the picture and means the deployed + # console and a developer's machine take the same code path. + location /fiesta/ { + proxy_pass https://fiesta.nearle.app/; + proxy_ssl_server_name on; + proxy_set_header Host fiesta.nearle.app; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + } + + # ── Catalogue ingest ───────────────────────────────────────────────────── + # + # The API key is attached HERE, by nginx, from an environment variable set + # on the container. It never reaches the browser — which matters more than + # usual for this one: the key carries `require_admin` on that service, which + # is a superuser, so the same key also reaches /api/catalog/generate and + # /api/system/init. A key compiled into the JavaScript bundle is a key + # handed to every visitor. + # + # This also settles CORS. The owning team's allow-list does not include this + # console's origin and should not need to: the browser only ever talks to + # its own host, and this container makes the cross-origin call. + location /ingest/ { + # ── Where the ingest service is, and how we prove who we are ───────── + # + # Both are environment variables so the deployment can move between two + # arrangements without a rebuild: + # + # INGEST_UPSTREAM=https://mcp.nearle.ai.in INGEST_TOKEN= + # The public host. Needs a credential, because that host is on the + # internet and its guards do not care who is asking. + # + # INGEST_UPSTREAM=http://: INGEST_TOKEN= + # The internal Docker network. `app.nearledaily.com` and + # `mcp.nearle.ai.in` both resolve to 72.60.218.25 — the same host — + # so the two containers can talk without going out to the internet + # and back. No secret has to exist on this side at all, which is + # the whole point: a credential that is never issued cannot leak, + # expire, or be pasted into a chat window. + # + # The second needs the ingest service to trust its own machine. That is + # their change, not ours; this side is ready for either. + set $ingest_token "${INGEST_TOKEN}"; + + # No 503 guard for a missing token any more, deliberately. + # + # It existed because an unset token sent no header and the service + # answered with the same flat 401 it gives a wrong key. Two problems, + # one message. It cannot stay: on the internal network an empty token is + # the CORRECT configuration, and a guard that refuses the intended setup + # is worse than the ambiguity it was written to remove. The service's own + # 401 now names the fix — "Send a bearer token ... or an X-API-Key + # header" — which is the sentence the guard was standing in for. + # + # nginx omits a header whose value is empty, so the line below sends + # `X-API-Key` on the public host and nothing at all internally. One + # directive, both modes, no branching. + + # `${INGEST_UPSTREAM}` and not `$upstream_variable`, and the difference + # is not cosmetic. + # + # envsubst rewrites this line at container start, so nginx parses a + # literal address and behaves exactly as it did when the host was + # hardcoded. Putting an nginx VARIABLE in proxy_pass instead changes + # three things at once: nginx resolves the name per request rather than + # at startup, which requires a `resolver` directive; 127.0.0.11 (Docker's + # embedded DNS) exists only on a user-defined network, so on a default + # bridge every ingest call fails with "recv() failed ... while resolving"; + # and a variable proxy_pass stops stripping the location prefix, so the + # upstream starts receiving `/ingest/api/...` and 404s. Measured, not + # guessed — the first version of this did all three. + # + # The trailing slash is what strips `/ingest/`, so INGEST_UPSTREAM must + # NOT end in one. A name that cannot be resolved now fails at startup + # rather than per request, which is the better place to find out. + proxy_pass ${INGEST_UPSTREAM}/; + proxy_ssl_server_name on; + # Derived from the upstream rather than hardcoded, so it stays correct + # when the upstream becomes a container name. + proxy_set_header Host $proxy_host; + proxy_set_header X-API-Key $ingest_token; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + + # Ingest submits return 202 immediately, but a sheet near the size limit + # takes a moment to upload and the service can be slow to accept it. + proxy_read_timeout 300s; + proxy_send_timeout 300s; + # Send the upload straight through rather than spooling it to disk + # first — nginx would otherwise buffer the whole workbook before the + # upstream saw a byte. + proxy_request_buffering off; + } + + # The `/hasura/` block that used to be here is gone. It belonged to the old + # console (daily_merchant_web) and nothing in this app has ever called it — + # it also carried a Hasura admin secret hardcoded in plain text, committed + # to the repository. That secret should be rotated. +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5bdc52a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5160 @@ +{ + "name": "nearle-console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nearle-console", + "version": "0.1.0", + "dependencies": { + "@astryxdesign/core": "^0.4.5", + "@stylexjs/stylex": "^0.19.0", + "@tanstack/react-query": "^5.101.4", + "leaflet": "^1.9.4", + "lucide-react": "^1.33.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "recharts": "^3.10.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@astryxdesign/cli": "^0.4.5", + "@tailwindcss/vite": "^4.3.3", + "@types/jsdom": "^30.0.0", + "@types/leaflet": "^1.9.22", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^5.0.4", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "tsx": "^4.20.3", + "typescript": "^7.0.2", + "vite": "^8.2.2" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@astryxdesign/cli": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.4.5.tgz", + "integrity": "sha512-3kUYK5K5eO77O4trXmzG/GfwMBiFI2Y7MOfsVK3euHZk98zQX/Z04MXag+S89v5qeObS3S2LI27QitkIq7hoOQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "jiti": "^2.7.0", + "jscodeshift": "^17.3.0", + "zod": "^4.4.3" + }, + "bin": { + "astryx": "clients/cli/bin/astryx.mjs", + "cli": "clients/cli/bin/astryx.mjs" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@astryxdesign/charts": "*", + "@astryxdesign/core": "*", + "@astryxdesign/lab": "*", + "@astryxdesign/theme-neutral": "*", + "gpt-tokenizer": "^3.4.0" + }, + "peerDependenciesMeta": { + "@astryxdesign/charts": { + "optional": true + }, + "@astryxdesign/core": { + "optional": true + }, + "@astryxdesign/lab": { + "optional": true + }, + "@astryxdesign/theme-neutral": { + "optional": true + } + } + }, + "node_modules/@astryxdesign/core": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.4.5.tgz", + "integrity": "sha512-rbGaoAGZq1QImY2VWeWQNAh1ZqQa/KLWnmoOdy13lSjzMcAPDS6WrVv9fZfsNDx+DIG2oySLtMikwZk5WFl1Uw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "intl-messageformat": "^11.2.9" + }, + "peerDependencies": { + "@stylexjs/stylex": "^0.19.0", + "react": ">=19.0.0", + "react-dom": ">=19.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.29.7.tgz", + "integrity": "sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-flow-strip-types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/register": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.29.7.tgz", + "integrity": "sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "find-cache-dir": "^2.0.0", + "make-dir": "^2.1.0", + "pirates": "^4.0.6", + "source-map-support": "^0.5.16" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.17.tgz", + "integrity": "sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.11" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.11.tgz", + "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@stylexjs/stylex": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@stylexjs/stylex/-/stylex-0.19.0.tgz", + "integrity": "sha512-CnUFp7YMaDLDeemsWOfJgoC/gKM5P/yBNMcpJaE6ChJmXr7s0DJwSeGTTlHJcqqwN9OW1qGtmARWLFhGZN1pTA==", + "license": "MIT", + "dependencies": { + "css-mediaquery": "^0.1.2", + "invariant": "^2.2.4", + "styleq": "0.2.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^8.9.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.2.tgz", + "integrity": "sha512-7/+aSjzkUoLc92hV22bTW4aGanXf800zbwguhcICs0OAoCF9wDOE4wkopQ+SqfhXZm8mCK8gHpdTs7pZUWzK3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", + "license": "BSD" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.411", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz", + "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-toolkit": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz", + "integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/flow-estree": { + "version": "0.328.0", + "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.328.0.tgz", + "integrity": "sha512-uMB3dC4nfZYn+dd7/PYkyAK1mqBR4TJ4TWRdOjubpb/ObrLvPFdFSmMTq55fqttCCTygQ4NtD1Zdd+wcjC5t0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/flow-parser": { + "version": "0.328.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.328.0.tgz", + "integrity": "sha512-F+Ik2Of7ndl2FyPmZ77NrhbZp9lYjIsreDXeq4fo0sjxa3SIFv48QAszBqvPeMMzmw7JzqySJUxGR8CSD7EdXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flow-estree": "0.328.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/gpt-tokenizer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz", + "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/intl-messageformat": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.14.tgz", + "integrity": "sha512-9f2VD1HFuxUvMw0RxsaP8WmMns6JRTnsNB/zghTFrp11ZktiXWwVDeZBPQchBKEmo+Gx/ZhxI7Qht7YglFD4PA==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.17" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jscodeshift": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-17.4.0.tgz", + "integrity": "sha512-i3ESKiiTsGynxzTg5BhsZViD0ai72/6SsI1efDZxG6/5KCoElsmquxtyhXK5lpEgoO7MTNpYkjFEdEL97SkBNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/plugin-transform-class-properties": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/preset-flow": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@babel/register": "^7.24.6", + "flow-parser": "0.*", + "graceful-fs": "^4.2.4", + "neo-async": "^2.5.0", + "picocolors": "^1.0.1", + "picomatch": "^4.0.2", + "recast": "^0.23.11", + "tmp": "^0.2.3", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "jscodeshift": "bin/jscodeshift.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@babel/preset-env": "^7.1.6" + }, + "peerDependenciesMeta": { + "@babel/preset-env": { + "optional": true + } + } + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "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": ">= 12.0.0" + }, + "funding": { + "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/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/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/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/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, + "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, + "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, + "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, + "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/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.33.0.tgz", + "integrity": "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recast": { + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/rolldown/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/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/styleq": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.2.1.tgz", + "integrity": "sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==", + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.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": { + "@types/node": { + "optional": true + }, + "@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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "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/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "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/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "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/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "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/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "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/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "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/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "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/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "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/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "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/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "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/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "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/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd41482 --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "nearle-platform", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 3100 --host 0.0.0.0", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --port 3100", + "typecheck": "tsc --noEmit", + "test": "tsx --import ./tools/stub-css.mjs --test \"src/**/*.test.ts\" \"src/**/*.test.tsx\"", + "contract": "node scripts/contract.mjs", + "db": "node scripts/db.mjs", + "appgap": "node scripts/appgap.mjs", + "check:health": "tsx scripts/checkHealthPanel.ts", + "check:optimiser": "tsx scripts/checkOptimiser.ts", + "verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"", + "appsweep": "node scripts/appsweep.mjs", + "appfix": "node scripts/appfix.mjs", + "preview:map": "node scripts/mapPreview.mjs" + }, + "dependencies": { + "@astryxdesign/core": "^0.4.5", + "@stylexjs/stylex": "^0.19.0", + "@tanstack/react-query": "^5.101.4", + "leaflet": "^1.9.4", + "lucide-react": "^1.33.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "recharts": "^3.10.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@astryxdesign/cli": "^0.4.5", + "@tailwindcss/vite": "^4.3.3", + "@types/jsdom": "^30.0.0", + "@types/leaflet": "^1.9.22", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^5.0.4", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "tsx": "^4.20.3", + "typescript": "^7.0.2", + "vite": "^8.2.2" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..0239e38 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..5060a96 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/icon-16.png b/public/icon-16.png new file mode 100644 index 0000000..db77a1e Binary files /dev/null and b/public/icon-16.png differ diff --git a/public/icon-180.png b/public/icon-180.png new file mode 100644 index 0000000..70b4c7c Binary files /dev/null and b/public/icon-180.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..b04d5fa Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-32.png b/public/icon-32.png new file mode 100644 index 0000000..5060a96 Binary files /dev/null and b/public/icon-32.png differ diff --git a/public/icon-48.png b/public/icon-48.png new file mode 100644 index 0000000..56590cf Binary files /dev/null and b/public/icon-48.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..bd0d622 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/logo-512.png b/public/logo-512.png new file mode 100644 index 0000000..bd0d622 Binary files /dev/null and b/public/logo-512.png differ diff --git a/public/logo-wordmark-light.png b/public/logo-wordmark-light.png new file mode 100644 index 0000000..3da4859 Binary files /dev/null and b/public/logo-wordmark-light.png differ diff --git a/public/logo-wordmark.png b/public/logo-wordmark.png new file mode 100644 index 0000000..8c470b0 Binary files /dev/null and b/public/logo-wordmark.png differ diff --git a/scripts/appfix.mjs b/scripts/appfix.mjs new file mode 100644 index 0000000..03fe151 --- /dev/null +++ b/scripts/appfix.mjs @@ -0,0 +1,262 @@ +/** + * Repairs the products the customer app cannot show. + * + * node scripts/appfix.mjs # dry run — prints, writes nothing + * node scripts/appfix.mjs --apply # performs the repair + * node scripts/appfix.mjs --apply --tenant 1135 + * + * `appsweep.mjs` finds them; this puts them right. Same detection, so the two + * cannot disagree about what is broken. + * + * ── What it does, and why it is shaped like this ───────────────────────────── + * + * The repair is "give the product a category", and for a long time there was no + * way to do it. `products/update` writes only `productlocations.status` despite + * its name, and `importcatalogueproduct` took an existing product down a branch + * that corrected the PRICE and left the category alone — so re-importing, the + * obvious fix, appeared to work and changed nothing. + * + * That branch now also calls `UpdateProductCategory` + * (`services/productService.go`), which makes re-import the repair path. This + * script drives it: for each orphan it re-sends the original import with a real + * `categoryid`. + * + * REQUIRES THE FIXED BACKEND. Against the currently deployed one every call + * returns 200 and nothing changes, which is exactly the failure that makes this + * bug expensive — so the script verifies each product afterwards and reports + * what actually moved rather than what it asked for. + * + * ── What it deliberately does not do ───────────────────────────────────────── + * + * It sends `quantity: 0` and `stocktype: "in"`, so no stock ledger entry is + * written — `CreateProductLocation` only records stock when quantity > 0. The + * products already have their stock and this must not add to it. + * + * It re-sends each product's EXISTING price, cost and tax, because the same + * call updates pricing. Sending zeros would wipe the prices while fixing the + * category. + */ + +const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app'; +const WEB = `${BASE}/live/api/v1/web`; + +const apply = process.argv.includes('--apply'); +const tenantArg = process.argv.indexOf('--tenant'); +const onlyTenant = tenantArg > -1 ? Number(process.argv[tenantArg + 1]) : null; + +async function get(path, params = {}) { + const query = new URLSearchParams( + Object.entries(params).filter(([, v]) => v !== undefined && v !== ''), + ); + const response = await fetch(`${WEB}${path}?${query}`, { headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`); + const payload = await response.json().catch(() => null); + return payload?.details ?? payload?.data ?? null; +} + +async function post(path, body) { + const response = await fetch(`${WEB}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => null); + return { ok: response.ok && payload?.status !== false, status: response.status, payload }; +} + +async function tenantsWithOrphans() { + const seen = new Map(); + for (let page = 1; page <= 20; page++) { + const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? []; + const list = Array.isArray(rows) ? rows : []; + for (const t of list) if (t?.tenantid) seen.set(t.tenantid, t); + if (list.length < 200) break; + } + const out = []; + for (const tenant of seen.values()) { + if (onlyTenant && tenant.tenantid !== onlyTenant) continue; + const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; + const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []); + const orphans = products.filter((p) => !p.categoryid); + if (orphans.length === 0) continue; + + /** + * Where each orphan already sits. + * + * `getallproducts` is tenant-wide and carries NO locationid — the first + * version of this read `p.locationid` off it, got undefined, sent 0, and + * every repair came back "missing required field(s): locationid". Nothing + * was written, which is the one good thing about that failure. + * + * The outlet matters beyond passing validation. Import writes a + * productlocations row, so naming an outlet the product is NOT on would put + * it on that shelf — silently extending the product's reach as a side + * effect of a repair. Only an outlet where it is already stocked is safe: + * there the upsert lands on the existing row. + */ + const locations = (await get('/tenants/gettenantlocations', { tenantid: tenant.tenantid })) ?? []; + const placement = new Map(); + for (const loc of Array.isArray(locations) ? locations : []) { + const rows = (await get('/products/getlocationproducts', { + tenantid: tenant.tenantid, + locationid: loc.locationid, + pageno: 1, + pagesize: 500, + })) ?? []; + for (const row of Array.isArray(rows) ? rows : []) { + if (!placement.has(row.productid)) placement.set(row.productid, { loc, row }); + } + } + + out.push({ tenant, orphans, placement }); + } + return out; +} + +/** + * The category to file a product under. + * + * `gettenantcategories` is synthesised from the categories the tenant's own + * products already use, so it is the tenant's real answer rather than the + * master table's — which is unscoped and, for the tenants seen here, offered a + * category (1001) that the app does not browse. + * + * Refusing rather than guessing when the tenant has none: a wrong category is + * findable and fixable, but writing one at random across a live catalogue is + * not something a repair script should decide. + */ +async function categoryFor(tenantid) { + const rows = (await get('/products/gettenantcategories', { tenantid })) ?? []; + const usable = (Array.isArray(rows) ? rows : []).filter((r) => r?.categoryid > 0); + return usable[0]?.categoryid ?? null; +} + +const work = await tenantsWithOrphans(); + +if (work.length === 0) { + console.log('Nothing to repair — no product is missing a category.'); + process.exit(0); +} + +console.log(apply ? 'APPLYING repairs\n' : 'DRY RUN — nothing will be written. Pass --apply.\n'); + +let repaired = 0; +let unchanged = 0; +let refused = 0; + +for (const { tenant, orphans, placement } of work) { + const categoryid = await categoryFor(tenant.tenantid); + console.log(`${tenant.tenantid} ${tenant.tenantname} — ${orphans.length} to repair`); + + if (!categoryid) { + console.log(' SKIPPED: this tenant has no category of its own to file into.\n'); + refused += orphans.length; + continue; + } + + for (const p of orphans) { + const label = `${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`; + + if (!p.productbrand || !p.catalogueid) { + // Not imported from the global catalogue, so brand+catalogueid cannot + // address it and re-import is not available. Says so rather than + // reporting a success it did not achieve. + console.log(` ${label} — SKIPPED: no catalogue reference to re-import from`); + refused++; + continue; + } + + const at = placement.get(p.productid); + if (!at) { + console.log(` ${label} — SKIPPED: not stocked at any outlet, so there is no safe row to repair through`); + refused++; + continue; + } + + /** + * A product with no price must not be made visible. + * + * Repairing the category is what puts a product in front of shoppers, and + * the app has no price floor — `GetProducts` filters on category and outlet + * and nothing else. Fixing a product priced at 0 would not "restore" it; it + * would publish a free one. Idhayam Sesame Oil 500ml (7083) is in exactly + * this state, priced nowhere, and it wants a price before it wants a + * category. + */ + const price = Number(p.retailprice ?? 0); + if (!(price > 0)) { + console.log(` ${label} — SKIPPED: no price set. Repairing this would list it at ₹0. Price it first.`); + refused++; + continue; + } + + if (!apply) { + console.log( + ` ${label} → categoryid ${categoryid} (via ${at.loc.locationname ?? at.loc.locationid}, price ₹${price} unchanged)`, + ); + continue; + } + + const result = await post('/products/importcatalogueproduct', [ + { + tenantid: tenant.tenantid, + // An outlet the product ALREADY sits at, so the upsert lands on the + // existing row instead of putting it on a new shelf. The category + // itself is written to `products`, which is tenant-wide, so one call + // fixes the product everywhere. + locationid: at.loc.locationid, + brand: p.productbrand, + catalogueid: p.catalogueid, + categoryid, + subcategoryid: p.subcategoryid ?? 0, + // Zero, so CreateProductLocation writes no stock ledger entry — it only + // records stock when quantity > 0. These products already have their + // stock and a repair must not add to it. + quantity: 0, + stocktype: 'in', + status: at.row.productstatus || p.productstatus || 'Active', + // The product's OWN current values, re-sent unchanged. The same call + // updates pricing, and the location upsert sets productlocations.price + // from retailprice — verified equal for every product being repaired + // here, so this round-trips rather than overwriting an outlet price. + retailprice: price, + productcost: p.productcost ?? 0, + taxpercent: p.taxpercent ?? 0, + }, + ]); + + if (!result.ok) { + console.log(` ${label} — FAILED: HTTP ${result.status} ${result.payload?.message ?? ''}`); + refused++; + continue; + } + + // Verified, not assumed. The whole reason this bug survived is that the + // call that was supposed to fix it returned success and did nothing. + const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; + const after = (Array.isArray(groups) ? groups : []) + .flatMap((g) => g?.products ?? []) + .find((x) => x.productid === p.productid); + + if (after?.categoryid > 0) { + console.log(` ${label} → categoryid ${after.categoryid} ✓`); + repaired++; + } else { + console.log( + ` ${label} — NO CHANGE: the call succeeded but the category is still 0.` + + ' The backend fix is not deployed.', + ); + unchanged++; + } + } + console.log(); +} + +console.log(`repaired ${repaired} · unchanged ${unchanged} · skipped ${refused}`); +if (unchanged > 0) { + console.log( + '\nProducts reported NO CHANGE need the backend fix deployed' + + ' (services/productService.go — re-import must call UpdateProductCategory).', + ); + process.exit(1); +} diff --git a/scripts/appgap.mjs b/scripts/appgap.mjs new file mode 100644 index 0000000..b5beaff --- /dev/null +++ b/scripts/appgap.mjs @@ -0,0 +1,193 @@ +/** + * Why the customer app shows fewer products than the console does. + * + * node scripts/appgap.mjs + * npm run appgap 1135 1166 + * + * Straight at Fiesta — no Hasura, no admin secret, nothing to configure. The + * first version of this went at the database through Hasura, which was the + * wrong instrument twice over: it needed a secret, it 404'd on an admin API + * that is often disabled, and it answered a question about the DATABASE when + * the question is about what the API returns. This calls the very endpoint the + * app calls and compares it with what the console can see. + * + * ── The three filters ──────────────────────────────────────────────────────── + * + * `getproductsbysubcategory` drops a product for one of three reasons, and none + * of them is an error, logged or visible from either end: + * + * A. `WHERE a.categoryid = 2` — not optional (`productRepository.go:865`). + * A product in another category cannot appear, whatever else is true. + * + * B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. No row + * for this outlet means the join yields NULL and the WHERE drops it. Being + * in the catalogue is not the same as being on a shelf. + * + * C. The grouping in `GetProductsBySubcategory` collects products under each + * real subcategory of category 2, then sweeps up `subcategoryid = 0` as + * "Uncategorized". A subcategoryid that is non-zero and NOT a subcategory + * of 2 matches neither and vanishes — present in the SQL, absent from the + * JSON. + */ + +/** + * Through the console's own host, not Fiesta directly. + * + * `app.nearledaily.com/fiesta/...` is the nginx proxy the deployed console + * already uses, so this script exercises exactly the path the browser takes — + * if the proxy is misconfigured this finds out, where hitting fiesta.nearle.app + * would quietly work and prove nothing about production. + * + * Override with FIESTA_URL to point at the backend directly or at a dev server. + */ +const BASE = process.env.FIESTA_URL ?? 'https://app.nearledaily.com/fiesta'; +const WEB = `${BASE}/live/api/v1/web`; +const MOB = `${BASE}/live/api/v1/mob`; + +const [, , tenantArg, locationArg] = process.argv; +const tenantid = Number(tenantArg); +const locationid = Number(locationArg); + +if (!tenantid || !locationid) { + console.error('Usage: node scripts/appgap.mjs '); + process.exit(1); +} + +/** Fiesta answers under `details` in most places and `data` in a few. */ +async function get(url, params) { + const query = new URLSearchParams( + Object.entries(params).filter(([, value]) => value !== undefined && value !== ''), + ); + let response; + try { + response = await fetch(`${url}?${query}`, { headers: { Accept: 'application/json' } }); + } catch (cause) { + console.error(`Could not reach ${BASE} — ${cause.message}`); + process.exit(1); + } + const payload = await response.json().catch(() => null); + if (!payload) { + console.error(`Malformed response from ${url} (HTTP ${response.status})`); + process.exit(1); + } + return payload.details ?? payload.data ?? null; +} + +/** + * Every product the tenant owns. + * + * `getallproducts` answers `[]models.Tenantproducts` — `{tenant, products}` + * groups, not a flat list — and under `data` rather than `details`. + */ +async function allProducts() { + const groups = await get(`${WEB}/products/getallproducts`, { tenantid }); + if (!Array.isArray(groups)) return []; + return groups.flatMap((group) => group?.products ?? []); +} + +/** + * What is actually listed at this outlet. + * + * Paged, and the page size matters: the default is 50, so a shop with 200 + * products would look like one with 50 and every product past the first page + * would be miscounted as "not listed". Walked until a short page comes back. + */ +async function locationProducts() { + const rows = []; + const pagesize = 200; + for (let pageno = 1; pageno <= 50; pageno += 1) { + const page = await get(`${WEB}/products/getlocationproducts`, { + tenantid, + locationid, + pageno, + pagesize, + }); + const batch = Array.isArray(page) ? page : []; + rows.push(...batch); + if (batch.length < pagesize) break; + } + return rows; +} + +/** Exactly what the app asks for, so the comparison is against reality. */ +async function appView() { + const payload = await get(`${MOB}/products/getproductsbysubcategory`, { + categoryid: 2, + tenantid, + locationid, + }); + const details = payload?.details ?? (Array.isArray(payload) ? payload : []); + return Array.isArray(details) ? details : []; +} + +const [products, listedRows, groups, subcategories] = await Promise.all([ + allProducts(), + locationProducts(), + appView(), + get(`${WEB}/products/getproductsubcategories`, { tenantid, categoryid: 2 }), +]); + +if (products.length === 0) { + console.log(`Tenant ${tenantid} has no products at all — nothing for the app to show.`); + process.exit(0); +} + +const listed = new Set(listedRows.map((row) => row.productid)); +/** + * `subcatid`/`subcatname`, not `subcategoryid`/`subcategoryname`. + * + * This read the long names — the ones `getproductsubcategories` does NOT send — + * so every entry was `undefined → undefined`, the map collapsed to a single + * junk key, and check C below could never match. The footer duly announced + * "(none returned)" for tenant 1147, whose category 2 has six subcategories. + * A diagnostic that is confidently wrong is worse than one that is missing. + */ +const realSubs = new Map( + (Array.isArray(subcategories) ? subcategories : []).map((row) => [row.subcatid, row.subcatname]), +); + +const inApp = new Set(); +for (const group of groups) { + for (const product of group.products ?? []) inApp.add(product.productid); +} + +const buckets = new Map(); +const examples = new Map(); + +for (const product of products) { + let reason; + if (inApp.has(product.productid)) { + reason = 'OK — the app shows this'; + } else if (product.categoryid !== 2) { + reason = `A — categoryid is ${product.categoryid}, the app only asks for 2`; + } else if (!listed.has(product.productid)) { + reason = 'B — not listed at this outlet (no productlocations row)'; + } else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) { + reason = `C — subcategoryid ${product.subcategoryid} is not a subcategory of 2, so it is dropped`; + } else { + // Everything checks out and it still is not there. Worth its own bucket + // rather than being folded into one of the above: a wrong guess here would + // send someone fixing data that is already correct. + reason = '? — passes all three checks but the app still does not return it'; + } + const key = reason.replace(/\d+/g, 'N'); + buckets.set(key, (buckets.get(key) ?? 0) + 1); + if (!examples.has(key)) examples.set(key, { product, reason }); +} + +console.log(`Tenant ${tenantid}, outlet ${locationid}`); +console.log(` ${products.length} products in the catalogue`); +console.log(` ${listed.size} listed at this outlet`); +console.log(` ${inApp.size} returned by the app's endpoint\n`); + +for (const [key, count] of [...buckets.entries()].sort((a, b) => b[1] - a[1])) { + const { product, reason } = examples.get(key); + console.log(` ${String(count).padStart(5)} ${reason}`); + console.log( + ` e.g. "${product.productname}" — id ${product.productid}, category ${product.categoryid}, subcategory ${product.subcategoryid}`, + ); +} + +console.log( + `\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none returned)'}`, +); diff --git a/scripts/appsweep.mjs b/scripts/appsweep.mjs new file mode 100644 index 0000000..35b6ed5 --- /dev/null +++ b/scripts/appsweep.mjs @@ -0,0 +1,246 @@ +/** + * Every product on the platform the customer app cannot show, and why. + * + * node scripts/appsweep.mjs + * node scripts/appsweep.mjs --json > sweep.json + * + * `appgap.mjs` answers this for ONE outlet and is the tool to reach for when + * somebody reports a specific shop. This is the platform-wide version: it walks + * every tenant, in every approval state, and reports the products that are + * unreachable no matter what the app asks for. + * + * ── The rule it applies ────────────────────────────────────────────────────── + * + * Read off the backend rather than inferred from responses + * (`controllers/productController.go:431`, `repositories/productRepository.go:858`): + * + * 1. `GetProductsBySubcategory` REJECTS `categoryid = 0` with a 400 — + * "Valid categoryid is required". It is the first thing the controller + * does. + * 2. The query then filters `WHERE a.categoryid = ?` unconditionally. + * + * Together those mean a product stored with `categoryid = 0` is returned for NO + * request the app can make. Not "usually hidden" — unreachable. It is still + * listed by `getlocationproducts`, which does not filter on category, so the + * console shows it and the shop believes it is on sale. + * + * Nothing else in that query gates visibility: there is no filter on + * `approved`, `publishedat`, `productstatus`, or stock level. A product with + * zero stock still appears. So `categoryid = 0` is the whole defect, and this + * script looks for exactly it. + */ + +const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app'; +const WEB = `${BASE}/live/api/v1/web`; + +const asJson = process.argv.includes('--json'); + +/** Fiesta answers under `details` in most places and `data` in a few. */ +async function get(path, params = {}) { + const query = new URLSearchParams( + Object.entries(params).filter(([, v]) => v !== undefined && v !== ''), + ); + const response = await fetch(`${WEB}${path}?${query}`, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`); + const payload = await response.json().catch(() => null); + return payload?.details ?? payload?.data ?? null; +} + +/** Requests that never succeeded. A non-empty list invalidates the report. */ +const failures = []; + +/** + * Bounded concurrency, with retries, and failures that are never swallowed. + * + * The first version of this caught every error and substituted `null`. Run + * across 262 tenants at a concurrency of 8, enough requests were refused that + * it reported ONE affected tenant out of three known ones — and reported it as + * a clean result, with no indication anything had gone wrong. A sweep that + * fails silently is worse than no sweep: it is used to close the investigation. + * + * So: three attempts with a widening delay, and anything still failing is + * recorded and printed at the end as an explicit gap in coverage. + */ +async function mapLimit(items, limit, fn, label = 'request') { + const out = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = next++; + if (i >= items.length) return; + let lastError; + for (let attempt = 0; attempt < 3; attempt++) { + try { + out[i] = await fn(items[i], i); + lastError = null; + break; + } catch (cause) { + lastError = cause; + await new Promise((r) => setTimeout(r, 250 * (attempt + 1))); + } + } + if (lastError) { + out[i] = null; + failures.push(`${label}[${i}]: ${lastError?.message ?? lastError}`); + } + } + }), + ); + return out; +} + +/** + * Every tenant, from two sources because neither is complete on its own. + * + * `getalltenants` paginates and its `pageno` is 1-BASED — page 0 returns an + * empty list rather than the first page, the same off-by-one that + * `getlocationproducts` has. It carries 262 tenants where the status lists + * carry 142. + * + * `/tenants/search` is still needed alongside it: it branches on the word + * "pending" and queries `approved = 0` instead of a status + * (`tenantRepository.go:45-77`), which is the only way to learn that a tenant + * is unapproved. Suriya Store is one. + * + * Building from the status lists ALONE was the first version's other bug: R + * mart (1147) appears in none of the three, and a sweep that cannot see a + * tenant reports it as having nothing wrong. + */ +async function allTenants() { + const seen = new Map(); + + for (let page = 1; page <= 20; page++) { + const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? []; + const list = Array.isArray(rows) ? rows : []; + for (const t of list) if (t?.tenantid && !seen.has(t.tenantid)) seen.set(t.tenantid, { ...t }); + if (list.length < 200) break; + } + + for (const status of ['Active', 'pending', 'InActive']) { + const rows = (await get('/tenants/search', { status })) ?? []; + for (const t of Array.isArray(rows) ? rows : []) { + if (!t?.tenantid) continue; + const existing = seen.get(t.tenantid) ?? { ...t }; + seen.set(t.tenantid, { ...existing, approvalState: status }); + } + } + + return [...seen.values()]; +} + +const tenants = await allTenants(); +if (!asJson) console.error(`Scanning ${tenants.length} tenants…`); + +/* Pass one — the whole platform, one request per tenant. + `getallproducts` returns {tenant, products} groups, not a flat list. */ +const scanned = await mapLimit(tenants, 8, async (tenant) => { + const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; + const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []); + return { tenant, products, orphans: products.filter((p) => !p.categoryid) }; +}, "tenant-products"); + +const affected = scanned.filter((row) => row && row.orphans.length > 0); + +/* Pass two — only the tenants that failed, so the outlet detail costs nothing + on a clean platform. */ +const detailed = await mapLimit(affected, 6, async (row) => { + const locations = (await get('/tenants/gettenantlocations', { tenantid: row.tenant.tenantid })) ?? []; + const branches = await mapLimit(Array.isArray(locations) ? locations : [], 4, async (loc) => { + const shelved = (await get('/products/getlocationproducts', { + tenantid: row.tenant.tenantid, + locationid: loc.locationid, + pageno: 1, + pagesize: 500, + })) ?? []; + const list = Array.isArray(shelved) ? shelved : []; + const hidden = list.filter((p) => !p.categoryid); + return { + locationid: loc.locationid, + locationname: loc.locationname, + shelved: list.length, + hidden: hidden.length, + // The number that matters to a shopper: an outlet whose entire range is + // invisible looks like a closed shop, not like a partial catalogue. + visible: list.length - hidden.length, + }; + }); + return { ...row, branches: branches.filter(Boolean) }; +}, "tenant-branches"); + +/* Coverage is part of the result, not a footnote. A tenant whose products + never loaded is UNKNOWN, not clean, and the difference decides whether this + report can be used to say the platform is fixed. */ +const unreached = scanned.filter((row) => !row).length; + +if (asJson) { + console.log( + JSON.stringify( + { + scannedTenants: tenants.length, + affectedTenants: detailed.length, + orphanProducts: detailed.reduce((n, r) => n + r.orphans.length, 0), + tenants: detailed.map((r) => ({ + tenantid: r.tenant.tenantid, + tenantname: r.tenant.tenantname, + approvalState: r.tenant.approvalState, + totalProducts: r.products.length, + orphans: r.orphans.map((p) => ({ + productid: p.productid, + productname: p.productname, + productbrand: p.productbrand, + catalogueid: p.catalogueid, + retailprice: p.retailprice, + })), + branches: r.branches, + })), + }, + null, + 2, + ), + ); +} else { + const orphanCount = detailed.reduce((n, r) => n + r.orphans.length, 0); + const blindOutlets = detailed.flatMap((r) => + r.branches.filter((b) => b.shelved > 0 && b.visible === 0), + ); + + console.log(`\n${tenants.length} tenants scanned`); + console.log(`${detailed.length} affected`); + console.log(`${orphanCount} products with categoryid 0 — invisible in the app`); + console.log(`${blindOutlets.length} outlets stocked but showing NOTHING to shoppers\n`); + + if (unreached > 0 || failures.length > 0) { + console.log( + `!! ${unreached} tenants could not be read after 3 attempts — this report is INCOMPLETE +`, + ); + for (const f of failures.slice(0, 10)) console.log(` ${f}`); + if (failures.length > 10) console.log(` … ${failures.length - 10} more +`); + console.log(); + } + + for (const row of detailed.sort((a, b) => b.orphans.length - a.orphans.length)) { + const { tenant } = row; + console.log( + `${tenant.tenantid} ${tenant.tenantname}` + + ` — ${row.orphans.length}/${row.products.length} products hidden` + + (tenant.approvalState === 'pending' ? ' [unapproved]' : ''), + ); + for (const p of row.orphans) { + console.log(` ${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`); + } + for (const b of row.branches) { + if (b.shelved === 0) continue; + const flag = b.visible === 0 ? ' ← app shows an EMPTY shop' : ''; + console.log( + ` · ${String(b.locationname ?? b.locationid).padEnd(28)}` + + ` ${b.visible}/${b.shelved} visible${flag}`, + ); + } + console.log(); + } +} diff --git a/scripts/checkHealthPanel.ts b/scripts/checkHealthPanel.ts new file mode 100644 index 0000000..1aaacda --- /dev/null +++ b/scripts/checkHealthPanel.ts @@ -0,0 +1,91 @@ +/** + * What the health panel would render, for real products, against the live + * service. + * + * Not a test — the tests pin behaviour against a frozen fixture. This runs the + * SAME functions the panel calls against whatever the service is returning + * right now, which is the only way to catch the service changing under us. + * + * npx tsx scripts/checkHealthPanel.ts + */ + +import { nutritionApi, __resolveBrand } from '../src/api/nutrition'; +import { facts, present, BAND_LABEL } from '../src/features/store-admin/healthScore'; + +const CASES: { label: string; brand: string; imageId: string; expect: string }[] = [ + { + label: 'Cadbury 5 Star 200g', + brand: 'Cadbury', + imageId: 'cadbury_cadbury_5_star_200g', + expect: 'a score, with a low-confidence caveat', + }, + { + label: 'Godrej Hit Spray (INSECTICIDE)', + brand: 'Godrej', + imageId: 'godrej_hit_spray_1101d017', + expect: 'NO score — blocked by the edibility guard', + }, + { + label: 'Naga Sooji', + brand: 'Naga', + imageId: 'naga_naga_sooji_100g', + expect: 'a high score, allergen declared', + }, + { + label: 'Aachi Baby Fryums 100g (a real merchant product)', + brand: 'Aachi', + imageId: 'aachi_aachi_baby_fryums_100g', + expect: 'known but unscored', + }, +]; + +const line = (s = '') => console.log(s); + +for (const testCase of CASES) { + line(); + line('─'.repeat(72)); + line(`${testCase.label}`); + line(`expected: ${testCase.expect}`); + line('─'.repeat(72)); + + const raw = await nutritionApi.forProduct(testCase.brand, testCase.imageId); + const shown = present(raw); + + if (shown.isEmpty) { + line(' → "No health score available for this product yet."'); + continue; + } + if (shown.isPending) { + line(' → "This product is in the catalogue but has not been scored yet."'); + line(` (service sent health_score=${raw?.health_score}, category="${raw?.category}")`); + continue; + } + + line(` SCORE ${shown.display}/100 ${shown.band ? BAND_LABEL[shown.band] : ''}`); + for (const good of shown.good) line(` ✓ ${good}`); + for (const caution of shown.cautions) line(` ! ${caution}`); + if (shown.tags.length) line(` TAGS ${shown.tags.join(' · ')}`); + if (shown.allergens.length) line(` ALLERGENS Contains ${shown.allergens.join(', ')}`); + else if (shown.allergensUnconfirmed) line(' ALLERGENS not confirmed — check the pack'); + const rows = facts(raw); + if (rows.length) line(` PER 100g ${rows.map((r) => `${r.label} ${r.value}`).join(' · ')}`); + if (shown.caveat) line(` CAVEAT ${shown.caveat}`); + if (shown.source) line(` SOURCE ${shown.source.label}`); +} + +line(); + +/* ── Brand vocabularies ──────────────────────────────────────────────────── */ +/* Our catalogue writes snake_case, theirs writes Title Case with a separator + that is sometimes a space and sometimes a hyphen. A mismatch returns + health_score: null — indistinguishable from an unscored product — so this + checks the resolution rather than trusting it. */ + +line('─'.repeat(72)); +line('Brand resolution: our spelling → theirs'); +line('─'.repeat(72)); +for (const ours of ['cadbury', 'coca_cola', 'brooke_bond', '24_mantra', 'colgate_palmolive', 'aachi']) { + const theirs = await __resolveBrand(ours); + line(` ${ours.padEnd(20)} → ${theirs}`); +} +line(); diff --git a/scripts/checkOptimiser.ts b/scripts/checkOptimiser.ts new file mode 100644 index 0000000..f4c9083 --- /dev/null +++ b/scripts/checkOptimiser.ts @@ -0,0 +1,89 @@ +/** + * The route-plan chain, run against the live optimiser. + * + * Not a test — the unit tests pin `routePlan.ts` against a frozen fixture. This + * calls the real service with real order rows and pushes the answer through the + * same functions the drawer uses, which is the only way to notice the service + * changing shape under us. + * + * npm run check:optimiser + */ + +import { optimiserApi } from '../src/api/optimiser'; +import type { OrderRow, RiderInfo } from '../src/api/types'; +import { + applyReconcile, + commitProblem, + dirtyRiders, + planFromSequence, + splitRoutable, + planKms, + reorderStops, + unplaced, +} from '../src/features/store-admin/routePlan'; + +const line = (s = '') => console.log(s); + +/** Real Suriya Store geography: the RS Puram branch out to four drops. */ +const ORDERS: OrderRow[] = [ + { orderheaderid: 1, orderid: 'N-1', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0284', deliverylong: '77.0120', deliverycustomer: 'Peelamedu' }, + { orderheaderid: 2, orderid: 'N-2', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0050', deliverylong: '76.9508', deliverycustomer: 'RS Puram' }, + { orderheaderid: 3, orderid: 'N-3', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '10.9877', deliverylong: '76.9620', deliverycustomer: 'Ukkadam' }, + { orderheaderid: 4, orderid: 'N-4', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0183', deliverylong: '76.9724', deliverycustomer: 'Gandhipuram' }, + // No coordinates — must come back as unplaced rather than vanishing. + { orderheaderid: 5, orderid: 'N-5', deliverycustomer: 'No location on file' }, +] as OrderRow[]; + +const RIDER: RiderInfo = { userid: 9701, fullname: 'Meera Raj', contactno: '9000000001' }; + +line('─'.repeat(74)); +line('1 · SEQUENCE — send in a deliberately bad order, see what comes back'); +line('─'.repeat(74)); + +const { routable, unroutable } = splitRoutable(ORDERS); +const stops = await optimiserApi.sequence(routable); +let plan = planFromSequence(stops, RIDER); + +for (const stop of plan.riders[0]?.orders ?? []) { + line( + ` ${String(stop.step).padStart(2)} ${(stop.orderid ?? '').padEnd(5)} ` + + `${(stop.deliverycustomer ?? '').padEnd(22)} ` + + `${String(stop.previouskms ?? '').padStart(5)} km ` + + `cum ${String(stop.cumulativekms ?? '').padStart(5)} km ` + + `eta ${stop.eta ?? '-'}m actualkms ${stop.actualkms ?? '-'}`, + ); +} +line(` total ${planKms(plan).toFixed(1)} km`); + +const missed = [...unroutable, ...unplaced(routable, stops)]; +line(` unplaced: ${missed.length ? missed.map((o) => o.orderid).join(', ') : 'none'}`); +line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`); + +line(); +line('─'.repeat(74)); +line('2 · EDIT — move the first stop to last, which breaks the step numbers'); +line('─'.repeat(74)); + +plan = reorderStops(plan, RIDER.userid, 0, (plan.riders[0]?.orders.length ?? 1) - 1); +line(` dirty rounds: ${[...plan.dirty].join(', ')}`); +line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')} <- out of order`); +line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'NO'}`); +line(` reason: ${commitProblem(plan)}`); + +line(); +line('─'.repeat(74)); +line('3 · RECONCILE — the service repairs the step numbers'); +line('─'.repeat(74)); + +try { + const response = await optimiserApi.reconcile(dirtyRiders(plan)); + plan = applyReconcile(plan, response); + line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')}`); + line(` dirty rounds: ${plan.dirty.size === 0 ? 'none' : [...plan.dirty].join(', ')}`); + line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`); +} catch (error) { + line(` reconcile failed: ${error instanceof Error ? error.message : String(error)}`); + line(` commit still blocked? ${commitProblem(plan) !== '' ? 'YES — correct' : 'NO — WRONG'}`); +} + +line(); diff --git a/scripts/contract.mjs b/scripts/contract.mjs new file mode 100644 index 0000000..54e66b9 --- /dev/null +++ b/scripts/contract.mjs @@ -0,0 +1,434 @@ +/** + * The contract check. + * + * Signs in once, calls every endpoint the console reads, and reports what came + * back: the HTTP status, the envelope, whether `details` is an array or an + * object or null, how many rows, and — the part that matters — the keys on the + * first row against the keys `src/api/types.ts` says to expect. + * + * This exists because field-name drift is invisible until a real payload + * arrives, and it is the single most likely way connecting to the backend goes + * wrong. It has already happened once from fixtures alone: POS health returns + * `terminal_id` while the sales split returns `terminalid`, and an index + * signature on the type let the wrong one typecheck in silence. + * + * READ-ONLY. Nothing here writes. Every create, update and import is left to a + * person on a scratch tenant, because several of them are unscoped and one of + * them moves stock. + * + * Run: + * npm run contract (prompts for the password, hidden) + * + * Or, for CI, set NEARLE_EMAIL and NEARLE_PASSWORD in the environment. Neither + * is ever written into this file — see the note beside EMAIL below. + * + * Plain JavaScript on purpose: it runs with the node you already have, with no + * install step and no TypeScript loader in the way. + * + * The credentials are read from the environment and never printed, logged or + * written to a file. Put them in front of the command rather than in a script, + * and they stay out of your shell history if your shell is configured for it. + */ + +import { createInterface } from 'node:readline'; + +const BASE = process.env['NEARLE_API'] ?? 'https://fiesta.nearle.app'; + +/** + * `/web/pos`, not `/pos`. + * + * The `/v1/pos` group sits behind the terminal's session guard; the console's + * copies of the same reads are registered under `/v1/web/pos`. Sweeping the + * wrong one would report a surface the console never calls. + */ +const WEB = '/live/api/v1/web'; +const POS = '/live/api/v1/web/pos'; +const MOB = '/live/api/v1/mob'; + +/** + * The account to sweep with. + * + * The email defaults because it is not a secret. The PASSWORD is never + * defaulted and never written into this file: a password in source is + * committed, synced to every machine that clones the repo, and survives in the + * history after it is changed. It is read from the environment if set, and + * otherwise typed at the prompt below, where it is not echoed and does not + * reach the shell history. + */ +const EMAIL = process.env['NEARLE_EMAIL'] ?? 'care@nearle.in'; + +/** Reads a line without echoing it. */ +function askHidden(question) { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const onData = (char) => { + // Stop echoing everything except the newline that ends the answer. + if (char.toString() !== '\n' && char.toString() !== '\r' && char.toString() !== '\u0004') { + process.stdout.write('\u001b[2K\u001b[200D' + question + '*'.repeat(rl.line.length)); + } + }; + process.stdin.on('data', onData); + rl.question(question, (answer) => { + process.stdin.off('data', onData); + rl.close(); + process.stdout.write('\n'); + resolve(answer); + }); + }); +} + +const PASSWORD = + process.env['NEARLE_PASSWORD'] ?? (await askHidden(`Password for ${EMAIL}: `)); + +if (!PASSWORD) { + console.error('No password given — nothing to sign in with.'); + process.exit(1); +} + +async function call(path, init = {}) { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(init.params ?? {})) { + if (value === undefined || value === null || value === '') continue; + search.set(key, String(value)); + } + const query = search.toString(); + const response = await fetch(`${BASE}${path}${query ? `?${query}` : ''}`, { + method: init.method ?? 'GET', + headers: init.body + ? { Accept: 'application/json', 'Content-Type': 'application/json' } + : { Accept: 'application/json' }, + ...(init.body ? { body: JSON.stringify(init.body) } : {}), + }); + let envelope = {}; + try { + envelope = await response.json(); + } catch { + envelope = { message: 'not JSON' }; + } + return { http: response.status, envelope }; +} + +/** + * A call that reports a dead host rather than crashing the run. + * + * A wrong `NEARLE_API`, a VPN that is not up, or one endpoint timing out should + * leave the other twenty-four results on screen — a stack trace at check four + * tells you nothing about checks five to twenty-five. + */ +async function attempt(path, init = {}) { + try { + return await call(path, init); + } catch (cause) { + return { + http: 0, + envelope: { status: false, message: `could not reach the server (${String(cause)})` }, + }; + } +} + +/* ── Sign in ─────────────────────────────────────────────────────────────── */ + +const login = await attempt(`${WEB}/users/applogin`, { + method: 'POST', + // `configid` is not optional: the lookup is `WHERE authname = ? AND configid = ?`. + body: { authname: EMAIL, password: PASSWORD, configid: 1 }, +}); + +if (login.envelope.status !== true || !login.envelope.details) { + console.error( + `Sign-in failed — HTTP ${login.http}, code ${login.envelope.code}: ${login.envelope.message}`, + ); + process.exit(1); +} + +const me = login.envelope.details; +const tenantid = Number(me['tenantid'] ?? 0); +const locationid = Number(me['locationid'] ?? 0); +const issuperadmin = me['issuperadmin'] === true; + +console.log('── signed in ─────────────────────────────────────────────'); +console.log(`userid ${me['userid']} · roleid ${me['roleid']} · issuperadmin ${issuperadmin}`); +console.log(`tenantid ${tenantid} · locationid ${locationid} · ${me['locationname'] ?? '—'}`); +console.log('login keys:', Object.keys(me).sort().join(', ')); +console.log(''); + +/** + * A tenant and a branch to probe the scoped endpoints with. + * + * A super admin has neither of their own, so one is borrowed from the platform + * list. Override with NEARLE_TENANT / NEARLE_LOCATION to aim at a specific one. + */ +let probeTenant = Number(process.env['NEARLE_TENANT'] ?? 0) || tenantid; +let probeLocation = Number(process.env['NEARLE_LOCATION'] ?? 0) || locationid; + +if (!probeTenant) { + const tenants = await attempt(`${WEB}/tenants/getalltenants`, { + params: { pageno: 1, pagesize: 1 }, + }); + probeTenant = Number(tenants.envelope.details?.[0]?.['tenantid'] ?? 0); +} +if (probeTenant && !probeLocation) { + const locations = await attempt(`${WEB}/tenants/gettenantlocations`, { + params: { tenantid: probeTenant }, + }); + probeLocation = Number(locations.envelope.details?.[0]?.['locationid'] ?? 0); +} + +console.log(`probing with tenantid ${probeTenant} · locationid ${probeLocation}\n`); + +/* ── What we expect ──────────────────────────────────────────────────────── */ + +/** + * The keys each row should carry, taken from `src/api/types.ts`. + * + * Only the ones the console actually reads are listed — a backend that returns + * MORE than this is fine and normal, and is reported as extras rather than as a + * failure. What matters is anything missing. + */ +const CHECKS = [ + { + name: 'tenants/getalltenants', + path: `${WEB}/tenants/getalltenants`, + params: { pageno: 1, pagesize: 5 }, + expect: ['tenantid', 'tenantname', 'locationid', 'locationname', 'status'], + }, + { + name: 'tenants/search?pending', + path: `${WEB}/tenants/search`, + params: { status: 'pending' }, + expect: ['tenantid', 'tenantname'], + }, + { + name: 'tenants/gettenantlocations', + path: `${WEB}/tenants/gettenantlocations`, + params: { tenantid: probeTenant }, + needs: 'tenant', + expect: ['locationid', 'tenantid', 'locationname', 'status'], + }, + { + name: 'utils/getappcategories', + path: `${WEB}/utils/getappcategories`, + expect: ['categoryid', 'categoryname'], + }, + { + name: 'orders/getlocationsummary', + path: `${WEB}/orders/getlocationsummary`, + params: { tenantid: probeTenant }, + needs: 'tenant', + expect: ['locationid', 'locationname', 'total', 'delivered', 'cancelled'], + }, + { + name: 'orders/getordersummary', + path: `${WEB}/orders/getordersummary`, + params: { tenantid: probeTenant }, + needs: 'tenant', + expect: ['total', 'delivered', 'cancelled'], + }, + { + name: 'catalogue/getbrands', + path: `${WEB}/catalogue/getbrands`, + expect: ['brand', 'product_count'], + }, + { + name: 'catalogue/getproducts', + path: `${WEB}/catalogue/getproducts`, + params: { pageno: 1, pagesize: 5 }, + expect: ['id', 'brand', 'product_name'], + }, + { + name: 'products/getimportedcatalogueproducts', + path: `${WEB}/products/getimportedcatalogueproducts`, + params: { tenantid: probeTenant }, + needs: 'tenant', + expect: ['brand', 'catalogueid'], + }, + { + name: 'products/getproductcategories', + path: `${WEB}/products/getproductcategories`, + params: { tenantid: probeTenant }, + needs: 'tenant', + expect: ['categoryid', 'categoryname'], + }, + { + name: 'products/gettenantcategories', + path: `${WEB}/products/gettenantcategories`, + params: { tenantid: probeTenant }, + needs: 'tenant', + expect: ['categoryid', 'categoryname'], + }, + { + name: 'products/getlocationproducts', + path: `${WEB}/products/getlocationproducts`, + params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 }, + needs: 'location', + expect: ['productid', 'productname', 'price', 'publishedat', 'status'], + }, + { + name: 'products/getallproducts', + path: `${WEB}/products/getallproducts`, + params: { tenantid: probeTenant, pageno: 1, pagesize: 5 }, + needs: 'tenant', + expect: ['productid', 'productname'], + }, + { + name: 'products/getstockstatement', + path: `${WEB}/products/getstockstatement`, + params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 }, + needs: 'location', + expect: ['productid', 'opening', 'credit', 'debit', 'closing'], + }, + { + name: 'products/getstockrequests', + path: `${WEB}/products/getstockrequests`, + params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 }, + needs: 'tenant', + expect: ['requestid', 'productid', 'qty', 'status'], + }, + { + name: 'products/getsaletemplate', + path: `${WEB}/products/getsaletemplate`, + params: { tenantid: probeTenant, locationid: probeLocation }, + needs: 'tenant', + expect: ['tenantid', 'locations', 'products'], + }, + { + name: 'customers/gettenantcustomers', + path: `${WEB}/customers/gettenantcustomers`, + params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 }, + needs: 'tenant', + expect: ['customerid', 'firstname', 'contactno'], + }, + { + name: 'orders/getorders', + path: `${WEB}/orders/tenant/getorders`, + params: { + tenantid: probeTenant, + locationid: probeLocation, + fromdate: isoDaysAgo(30), + todate: isoDaysAgo(0), + pageno: 1, + pagesize: 5, + }, + needs: 'tenant', + expect: ['orderheaderid', 'orderstatus'], + }, + { + name: 'deliveries/getdeliveries', + path: `${WEB}/deliveries/getdeliveries`, + params: { + tenantid: probeTenant, + locationid: probeLocation, + fromdate: isoDaysAgo(30), + todate: isoDaysAgo(0), + pageno: 1, + pagesize: 5, + }, + needs: 'tenant', + expect: ['orderheaderid', 'orderstatus'], + }, + { + name: 'pos/sales/summary', + path: `${POS}/sales/summary`, + params: { locationid: probeLocation, fromdate: isoDaysAgo(7), todate: isoDaysAgo(0) }, + needs: 'location', + expect: ['billcount', 'grosssales', 'taxcollected'], + }, + { + name: 'pos/sales', + path: `${POS}/sales`, + params: { locationid: probeLocation, pageno: 0, pagesize: 5 }, + needs: 'location', + expect: ['bills', 'total'], + }, + { + name: 'pos/health/location', + path: `${POS}/health/location`, + params: { location_id: probeLocation }, + needs: 'location', + expect: ['total', 'online', 'terminals'], + }, + { + name: 'tenants/getposusers', + path: `${WEB}/tenants/getposusers`, + params: { tenantid: probeTenant, locationid: probeLocation }, + needs: 'location', + expect: ['users', 'location_id'], + }, + { + name: 'tenants/getstaffs (MOB)', + path: `${MOB}/tenants/getstaffs`, + params: { tenantid: probeTenant }, + needs: 'tenant', + expect: ['userid', 'rolename', 'firstname'], + }, + { name: 'tenants/posroles', path: `${WEB}/tenants/posroles`, expect: ['role_id', 'role'] }, + { + name: 'tenants/getstaffshifts', + path: `${WEB}/tenants/getstaffshifts`, + params: { tenantid: probeTenant, locationid: probeLocation }, + needs: 'location', + expect: ['shifts', 'location_id'], + }, +]; + +function isoDaysAgo(days) { + const date = new Date(); + date.setDate(date.getDate() - days); + return date.toISOString().slice(0, 10); +} + +/* ── Run ─────────────────────────────────────────────────────────────────── */ + +let mismatches = 0; +let unreachable = 0; + +for (const check of CHECKS) { + if (check.needs === 'tenant' && !probeTenant) { + console.log(`SKIP ${check.name} — no tenant to probe with`); + continue; + } + if (check.needs === 'location' && !probeLocation) { + console.log(`SKIP ${check.name} — no location to probe with`); + continue; + } + + const { http, envelope } = await attempt(check.path, { params: check.params }); + const payload = envelope.details ?? envelope.data; + + const shape = Array.isArray(payload) + ? `array(${payload.length})` + : payload === null || payload === undefined + ? 'null' + : typeof payload; + + // The row to inspect: the first element of a list, or the object itself. + const row = Array.isArray(payload) ? payload[0] : payload; + const keys = row && typeof row === 'object' ? Object.keys(row) : []; + const missing = check.expect.filter((key) => !keys.includes(key)); + + const ok = envelope.status !== false && http < 400 && missing.length === 0; + if (!ok) mismatches += 1; + if (http >= 400 || envelope.status === false) unreachable += 1; + + console.log( + `${ok ? 'OK ' : 'CHECK'} ${check.name.padEnd(38)} http ${http} · code ${envelope.code ?? '—'} · ${shape}`, + ); + if (envelope.status === false || http >= 400) { + console.log(` message: ${envelope.message ?? '(none)'}`); + } + if (missing.length > 0 && keys.length > 0) { + console.log(` MISSING: ${missing.join(', ')}`); + console.log(` got: ${keys.sort().join(', ')}`); + } + if (keys.length === 0 && shape !== 'null' && !Array.isArray(payload)) { + console.log(` payload: ${JSON.stringify(payload).slice(0, 160)}`); + } +} + +console.log(''); +console.log(`${CHECKS.length} checked · ${mismatches} to look at · ${unreachable} refused`); +console.log( + mismatches === 0 + ? 'Every endpoint answered in the shape the console expects.' + : 'Anything marked CHECK either refused the call or is missing a key the console reads.', +); diff --git a/scripts/db.mjs b/scripts/db.mjs new file mode 100644 index 0000000..061a47e --- /dev/null +++ b/scripts/db.mjs @@ -0,0 +1,399 @@ +/** + * Direct database access, through Hasura. + * + * A scratchpad for reading and fixing rows that no screen exposes — setting a + * password on an account that was spawned without one, flipping a status, + * checking what the API is actually reading. It talks to the Hasura instance + * the old console proxies to (`api.workolik.com`), using the admin secret from + * `daily_merchant_web/.env`, which is gitignored and stays there. + * + * node scripts/db.mjs tables + * node scripts/db.mjs user care@nearle.in + * node scripts/db.mjs setpw care@nearle.in + * node scripts/db.mjs sql "select userid, authname from app_users limit 5" + * + * The secret is read from disk or the environment and never printed, never + * written anywhere, and never passed on the command line. + * + * ── Read this before using `setpw` ──────────────────────────────────────── + * This points at PRODUCTION. Every write here is immediate and unversioned. + * `setpw` refuses to run unless the account's password column is already + * empty, so it can only ever complete a setup that was never finished — it + * cannot overwrite a working login. Lift that guard only deliberately. + * + * Passwords in `app_users` are stored in clear. That is a property of this + * backend, not of this script; anything written here is readable by anyone + * with database access. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** + * Where Hasura actually lives, discovered rather than assumed. + * + * The first version of this hardcoded `/v1/graphql` at the host root and got a + * 404. The old console's proxy is the clue it should have read: it rewrites + * `/hasura` to `/api/rest/`, which means Hasura is mounted under `/api`, not at + * the root. Rather than swap one guess for another, this tries the candidates + * and uses whichever answers. + * + * Override with HASURA_URL if it moves again — pass the full GraphQL URL. + */ +const ENDPOINT_CANDIDATES = process.env.HASURA_URL + ? [process.env.HASURA_URL] + : [ + 'https://api.workolik.com/api/v1/graphql', + 'https://api.workolik.com/v1/graphql', + 'https://api.workolik.com/hasura/v1/graphql', + ]; + +let ENDPOINT = ENDPOINT_CANDIDATES[0]; + +/** Finds the first candidate that answers a trivial query. */ +async function resolveEndpoint() { + for (const candidate of ENDPOINT_CANDIDATES) { + try { + const response = await fetch(candidate, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET }, + body: JSON.stringify({ query: '{ __typename }' }), + }); + if (!response.ok) continue; + const payload = await response.json().catch(() => null); + if (payload && !payload.errors) { + ENDPOINT = candidate; + return candidate; + } + } catch { + // Next candidate. + } + } + console.error( + 'Could not find the Hasura GraphQL endpoint. Tried:\n' + + ENDPOINT_CANDIDATES.map((c) => ` ${c}`).join('\n') + + '\nSet HASURA_URL to the full GraphQL URL and run again.', + ); + process.exit(1); +} + +/** Where the old console keeps its gitignored secret, relative to this repo. */ +const ENV_CANDIDATES = [ + resolve(HERE, '../../../nearle-daily/daily_merchant_web/.env'), + resolve(HERE, '../../daily_merchant_web/.env'), + 'D:/nearle-daily/daily_merchant_web/.env', +]; + +function readSecret() { + if (process.env.HASURA_ADMIN_SECRET) return process.env.HASURA_ADMIN_SECRET; + + for (const path of ENV_CANDIDATES) { + if (!existsSync(path)) continue; + const line = readFileSync(path, 'utf8') + .split(/\r?\n/) + .find((row) => row.startsWith('HASURA_ADMIN_SECRET=')); + if (!line) continue; + const value = line.slice('HASURA_ADMIN_SECRET='.length).trim().replace(/^["']|["']$/g, ''); + if (value) return value; + } + + console.error( + 'No admin secret found.\n' + + 'Expected HASURA_ADMIN_SECRET in one of:\n' + + ENV_CANDIDATES.map((p) => ` ${p}`).join('\n') + + '\nor set it in the environment for this command.', + ); + process.exit(1); +} + +const SECRET = readSecret(); + +async function gql(query, variables = {}) { + let response; + try { + response = await fetch(ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET }, + body: JSON.stringify({ query, variables }), + }); + } catch (cause) { + console.error(`Could not reach ${ENDPOINT} — ${cause.message}`); + process.exit(1); + } + + const payload = await response.json().catch(() => null); + if (!payload) { + console.error(`Malformed response (HTTP ${response.status})`); + process.exit(1); + } + if (payload.errors) { + for (const error of payload.errors) console.error(`✗ ${error.message}`); + process.exit(1); + } + return payload.data; +} + +/* ── Commands ─────────────────────────────────────────────────────────────── */ + +/** Every table Hasura has tracked. Start here if a query says "field not found". */ +async function tables() { + const data = await gql(`{ __schema { queryType { fields { name } } } }`); + const names = data.__schema.queryType.fields + .map((field) => field.name) + .filter((name) => !name.endsWith('_aggregate') && !name.endsWith('_by_pk')) + .sort(); + console.log(names.join('\n')); + console.log(`\n${names.length} tables`); +} + +const USER_FIELDS = `userid authname firstname lastname contactno roleid status tenantid locationid configid`; + +async function findUser(email) { + const data = await gql( + `query ($email: String!) { + app_users(where: { authname: { _eq: $email } }) { ${USER_FIELDS} password } + }`, + { email }, + ); + return data.app_users ?? []; +} + +async function user(email) { + const rows = await findUser(email); + if (rows.length === 0) { + console.log(`No account with authname "${email}".`); + return; + } + for (const row of rows) { + // The password itself is never printed — only whether one exists, which is + // the only thing anyone needs to know from here. + const { password, ...rest } = row; + console.log({ ...rest, haspassword: String(password ?? '').trim() !== '' }); + } +} + +/** + * Completes a password setup that was never finished. + * + * Refuses if a password is already set. An account that can sign in must not + * be changeable from a scratchpad — that is a support action with a person + * behind it, not a one-liner. + */ +async function setpw(email, password) { + if (!password || password.length < 6) { + console.error('Password must be at least 6 characters (the backend enforces this too).'); + process.exit(1); + } + + const rows = await findUser(email); + if (rows.length === 0) { + console.error(`No account with authname "${email}".`); + process.exit(1); + } + if (rows.length > 1) { + console.error( + `${rows.length} accounts share that email (configid ${rows.map((r) => r.configid).join(', ')}).\n` + + 'Refusing to guess. Use `sql` with an explicit userid.', + ); + process.exit(1); + } + + const row = rows[0]; + if (String(row.password ?? '').trim() !== '') { + console.error( + `userid ${row.userid} already has a password. This command only completes an unfinished setup.\n` + + 'To reset a working login, do it deliberately with `sql`.', + ); + process.exit(1); + } + if (row.roleid === 7 || row.roleid === 8) { + console.error( + `userid ${row.userid} is a till account (roleid ${row.roleid}). Those sign in at the terminal with a PIN, not here.`, + ); + process.exit(1); + } + + const data = await gql( + `mutation ($userid: Int!, $password: String!) { + update_app_users(where: { userid: { _eq: $userid } }, _set: { password: $password }) { + affected_rows + } + }`, + { userid: row.userid, password }, + ); + + const affected = data.update_app_users?.affected_rows ?? 0; + if (affected !== 1) { + console.error(`Expected to update 1 row, updated ${affected}. Nothing assumed — check manually.`); + process.exit(1); + } + console.log( + `✓ Password set on userid ${row.userid} (${email}), roleid ${row.roleid}, tenantid ${row.tenantid}.`, + ); + console.log(' Sign in at the console with it now.'); +} + +/** + * Arbitrary read-only SQL, via Hasura's `run_sql`. + * + * Reads only. A statement that writes is refused here — writes go through a + * named command above, where they can carry their own guard. + */ +async function sql(statement) { + if (/^\s*(insert|update|delete|drop|alter|truncate|create)\b/i.test(statement)) { + console.error('This command runs reads only. Add a named command for a write.'); + process.exit(1); + } + + const endpoint = ENDPOINT.replace(/\/v1\/graphql$/, '/v2/query'); + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET }, + body: JSON.stringify({ + type: 'run_sql', + args: { source: 'default', sql: statement, read_only: true }, + }), + }); + + const payload = await response.json().catch(() => null); + if (!response.ok || !payload) { + console.error(payload?.error ?? `HTTP ${response.status}`); + process.exit(1); + } + + const rows = payload.result ?? []; + for (const row of rows) console.log(row.join('\t')); + console.log(`\n${Math.max(0, rows.length - 1)} rows`); +} + +/** + * Why the app shows fewer products than the console does. + * + * node scripts/db.mjs appgap + * + * `getproductsbysubcategory` is what the customer app browses with, and three + * separate conditions decide whether a product survives it. None of them is an + * error and none of them is logged — a product that fails any one simply is not + * in the response, which is why the console can be full and the app empty. + * + * A. `WHERE a.categoryid = ?` — the caller passes 2, and the filter is not + * optional (`productRepository.go:865`). A product in any other category is + * invisible to this endpoint no matter what else is true of it. + * + * B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. A product + * with no row for THIS outlet joins to NULL, and the WHERE then drops it. + * Being in the catalogue is not the same as being on a shelf: something has + * to write `productlocations`, and nothing does that automatically. + * + * C. The grouping in `GetProductsBySubcategory` walks the real subcategories + * of category 2 and collects products matching each, then sweeps up + * everything with `subcategoryid = 0` as "Uncategorized". A product whose + * subcategoryid is non-zero but is NOT a subcategory of category 2 matches + * neither loop and vanishes — it is in the query results and absent from + * the response. This one is worth looking for first, because it looks like + * nothing at all. + */ +async function appgap(tenantid, locationid) { + const tid = Number(tenantid); + const lid = Number(locationid); + if (!tid || !lid) { + console.error('Usage: node scripts/db.mjs appgap '); + process.exit(1); + } + + // GraphQL, not `run_sql`. + // + // `run_sql` lives on Hasura's `/v2/query` admin API, which answered 404 here — + // it is disabled on managed instances and behind a different path on others. + // Three ordinary queries and the bucketing done in JS needs none of that, and + // works on any Hasura the admin secret can reach. + const data = await gql( + `query ($tid: Int!, $lid: Int!) { + products(where: { tenantid: { _eq: $tid } }) { + productid productname categoryid subcategoryid + } + productlocations(where: { tenantid: { _eq: $tid }, locationid: { _eq: $lid } }) { + productid + } + productsubcategories(where: { categoryid: { _eq: 2 } }) { + subcategoryid subcategoryname + } + }`, + { tid, lid }, + ); + + const products = data.products ?? []; + const listed = new Set((data.productlocations ?? []).map((row) => row.productid)); + const realSubs = new Map( + (data.productsubcategories ?? []).map((row) => [row.subcategoryid, row.subcategoryname]), + ); + + if (products.length === 0) { + console.log(`Tenant ${tid} has no products at all.`); + return; + } + + const buckets = new Map(); + const examples = new Map(); + for (const product of products) { + let reason; + if (product.categoryid !== 2) { + reason = 'A. categoryid is not 2 — the app only asks for category 2'; + } else if (!listed.has(product.productid)) { + reason = 'B. not listed at this outlet — no productlocations row'; + } else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) { + reason = 'C. subcategoryid is not a real subcategory of 2 — silently dropped'; + } else { + reason = 'OK. should appear in the app'; + } + buckets.set(reason, (buckets.get(reason) ?? 0) + 1); + if (!examples.has(reason)) examples.set(reason, product); + } + + console.log(`${products.length} products on tenant ${tid}\n`); + const ordered = [...buckets.entries()].sort((a, b) => b[1] - a[1]); + for (const [reason, count] of ordered) { + const sample = examples.get(reason); + console.log(` ${String(count).padStart(5)} ${reason}`); + console.log( + ` e.g. ${sample.productname} (id ${sample.productid}, category ${sample.categoryid}, subcategory ${sample.subcategoryid})`, + ); + } + + console.log(`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none)'}`); +} + +/* ── Dispatch ─────────────────────────────────────────────────────────────── */ + +const [command, ...rest] = process.argv.slice(2); + +const COMMANDS = { + tables: () => tables(), + user: () => user(rest[0]), + setpw: () => setpw(rest[0], rest[1]), + sql: () => sql(rest.join(' ')), + appgap: () => appgap(rest[0], rest[1]), +}; + +if (!command || !COMMANDS[command]) { + console.log( + [ + 'node scripts/db.mjs ', + '', + ' tables every table Hasura has tracked', + ' user show an account (never prints the password)', + ' setpw set a password on an account that has none', + ' sql " onChange(event.target.value)} + /> + {/* Only when there is something to clear. A permanently visible X on an + empty field is a control that does nothing, and it costs the + placeholder ten characters of room. */} + {value ? ( + + ) : null} + + ); +} diff --git a/src/components/SectionHeader.tsx b/src/components/SectionHeader.tsx new file mode 100644 index 0000000..d5f18e6 --- /dev/null +++ b/src/components/SectionHeader.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react'; +import { Heading } from '@astryxdesign/core/Heading'; +import { Text } from '@astryxdesign/core/Text'; +import { HStack } from '@astryxdesign/core/HStack'; + +export interface SectionHeaderProps { + title: string; + /** + * Sits inline beside the title, not beneath it — and only when it carries a + * fact, such as a count. + * + * It used to be required, on the theory that every section should say what it + * is showing. In practice that produced a line of editorial beside every + * heading — "how today is going", "a queue nobody can see from the shop + * floor" — which reads as noise once you have seen the page twice. The + * heading and the data under it say enough. + */ + note?: string; + /** A control or a "View all" link, right-aligned. */ + action?: ReactNode; +} + +export function SectionHeader({ title, note, action }: SectionHeaderProps) { + return ( + + + + {title} + + {note ? ( + + {note} + + ) : null} + + {action} + + ); +} diff --git a/src/components/SheetDropzone.tsx b/src/components/SheetDropzone.tsx new file mode 100644 index 0000000..0e69d19 --- /dev/null +++ b/src/components/SheetDropzone.tsx @@ -0,0 +1,235 @@ +import { useRef, useState, type DragEvent } from 'react'; +import { FileSpreadsheet, Trash2, Upload } from 'lucide-react'; + +/** + * The workbook dropzone. + * + * Astryx's `FileInput` in dropzone mode is a white slab with a dashed rule and + * a bare arrow — correct, and completely mute about what it wants or whether it + * is even available. This one says all three things: what it takes, whether it + * can take it right now, and what it is holding. + * + * Three states, and each looks different at a glance rather than on reading: + * + * - **Waiting** — a tinted panel with a brand-lit icon and an explicit + * "Choose file" affordance, because a dropzone that only accepts a drag is + * unusable to anyone on a laptop with the file already in a dialog. + * - **Dragging** — the brand colour comes up and the panel lifts. A dropzone + * that does not visibly react is one people drop next to. + * - **Filled** — the file itself, with its size and a way to swap it. The old + * control kept saying "Drop an .xlsx here" after you already had. + * + * It stays a real `` under the surface, so the keyboard, the + * screen reader and the OS file dialog all behave as they should — the div is + * decoration over an input, not a replacement for one. + */ +export interface SheetDropzoneProps { + file: File | null; + onFile: (file: File | null) => void; + /** Comma-separated extensions, passed straight to the input. */ + accept?: string; + /** Why the control is unavailable. Present means disabled. */ + blockedReason?: string; +} + +const ACCEPTED = ['.xlsx', '.xls', '.csv']; + +export function SheetDropzone({ + file, + onFile, + accept = ACCEPTED.join(','), + blockedReason, +}: SheetDropzoneProps) { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const isDisabled = Boolean(blockedReason); + + function open() { + if (!isDisabled) inputRef.current?.click(); + } + + function handleDrop(event: DragEvent) { + event.preventDefault(); + setIsDragging(false); + if (isDisabled) return; + const dropped = event.dataTransfer.files?.[0]; + if (dropped) onFile(dropped); + } + + /* ── Filled ───────────────────────────────────────────────────────────── */ + + if (file) { + return ( +
+ + + + +
+
+ {file.name} +
+
+ {formatSize(file.size)} +
+
+ + + + + onFile(event.target.files?.[0] ?? null)} + style={{ display: 'none' }} + /> +
+ ); + } + + /* ── Waiting / dragging ───────────────────────────────────────────────── */ + + return ( +
{ + event.preventDefault(); + if (!isDisabled) setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + role="button" + tabIndex={isDisabled ? -1 : 0} + aria-disabled={isDisabled} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + open(); + } + }} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 8, + padding: '16px 20px', + borderRadius: 'var(--card-radius)', + border: `1.5px dashed ${ + isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))' + }`, + background: isDragging + ? 'color-mix(in oklab, var(--color-brand) 8%, transparent)' + : 'color-mix(in oklab, var(--color-brand) 3%, transparent)', + cursor: isDisabled ? 'not-allowed' : 'pointer', + opacity: isDisabled ? 0.55 : 1, + textAlign: 'center', + outline: 'none', + transition: 'background .16s ease, border-color .16s ease, transform .16s ease', + transform: isDragging ? 'scale(1.004)' : 'none', + }} + > + + + + +
+
+ {isDisabled + ? blockedReason + : isDragging + ? 'Drop it here' + : 'Drop your workbook, or click to browse'} +
+
+ {ACCEPTED.join(' · ')} +
+
+ + onFile(event.target.files?.[0] ?? null)} + style={{ display: 'none' }} + /> +
+ ); +} + +const ghostButtonStyle: React.CSSProperties = { + flex: 'none', + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '7px 12px', + borderRadius: 9, + border: '1px solid var(--color-line)', + background: 'var(--color-surface)', + color: 'var(--color-ink-2)', + fontSize: 12.5, + fontWeight: 600, + fontFamily: 'inherit', + cursor: 'pointer', +}; + +/** kB up to a megabyte, then MB. Nobody needs "1420800 bytes". */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${Math.round(kb)} kB`; + return `${(kb / 1024).toFixed(1)} MB`; +} diff --git a/src/components/StickyRow.tsx b/src/components/StickyRow.tsx new file mode 100644 index 0000000..392b723 --- /dev/null +++ b/src/components/StickyRow.tsx @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; + +/** + * A row of page controls held under the nav bar while the page scrolls. + * + * Whatever sits above a page — its view tabs, its search, its action buttons — + * used to leave with the scroll. Reaching the bottom of a long list put every + * control over that list off the top of the window, and the only way back to + * them was to scroll back through everything just read. The further in somebody + * was, the further from the controls. + * + * The page still scrolls normally. Nothing here gets a scrollbar of its own — + * the row simply stays where it is. Positioning and the canvas it paints live + * in `.page-sticky`; this component exists for the one part CSS cannot answer, + * which is whether the row is currently pinned. + * + * `PageHeader` wraps itself in this, so every page built from it gets the + * behaviour without asking. Dispatch builds its own switcher rather than going + * through `PageHeader`, so it wraps itself — which is the reason this is a + * component and not four more lines inside the header. + */ +export function StickyRow({ children }: { children: ReactNode }) { + /** + * Pinned or not, so the hairline can be drawn only when it is. + * + * At rest the nav bar's own hairline sits just above this one, and two + * parallel lines across the page with nothing between them read as a mistake. + * The line is what separates the row from content passing UNDER it, so it + * belongs to the pinned state rather than to the row. + * + * Watched with an observer rather than a scroll handler: a scroll listener + * runs on every frame of every scroll to answer a question that changes + * twice. The negative top margin puts the observer's boundary a pixel above + * where the row comes to rest, so "no longer fully inside that box" means + * exactly "stuck". + */ + const rowRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + useEffect(() => { + const element = rowRef.current; + /* Guarded for any renderer without it — the row still sticks, it just + never grows its line, which is a missing hairline rather than a crash. */ + if (!element || typeof IntersectionObserver === 'undefined') return; + + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(Boolean(entry && !entry.isIntersecting)), + { rootMargin: '-57px 0px 0px 0px', threshold: 1 }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx new file mode 100644 index 0000000..d112e59 --- /dev/null +++ b/src/components/TabBar.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from 'react'; +import './tabBar.css'; + +/** + * The console's view switcher — Orders / Deliveries / Counter sales, Catalogue + * / My stock / My requests, Store users / Terminal accounts / Riders. + * + * ── There were six of these ───────────────────────────────────────────────── + * + * `TabButton` was defined privately in `StoresPage`, `InventoryPage`, + * `ReportsPage`, `SalesPage`, `UsersPage` and `store-user/ui.tsx` — six copies + * of the same button, each styled inline, and they had already drifted: three + * of them filled the count badge with `--color-warning`, so "85" under Orders + * was drawn in the colour this console reserves for something being wrong. A + * count is not an alarm. Sales had the sensible version. Nobody could see the + * difference because no two of them are ever on screen together. + * + * ── Why it is a track and not loose pills ─────────────────────────────────── + * + * The old buttons were transparent until selected, so an unselected set read as + * four pieces of text floating above the page with no indication they were one + * control or that picking one deselects the others. They sit in a recessed + * track now, with the selected tab raised out of it on the card surface — the + * standard segmented control, and the one shape that says "these are the views, + * you are in this one" without needing a label to say so. + */ + +export interface TabBarProps { + children: ReactNode; + /** `sm` for a secondary strip — a filter under a primary switcher. */ + size?: 'md' | 'sm'; + 'aria-label'?: string; +} + +export function TabBar({ children, size = 'md', ...rest }: TabBarProps) { + return ( +
+ {children} +
+ ); +} + +export interface TabProps { + label: string; + icon?: ReactNode; + isActive: boolean; + onClick: () => void; + /** + * The count beside the label. Omit it and no count is drawn. + * + * Whether ZERO is worth drawing depends on what the tab is, so the caller + * decides rather than this component: + * + * - A VIEW passes `x.length || undefined`. "Deliveries 0" beside "Orders + * 85" reads as a broken tab; "Deliveries" alone says nothing and lets the + * empty state explain. + * - A STATUS in a ladder passes the raw count. "Cancelled 0" is the answer + * to a question the reader is asking — nothing was cancelled — and a rung + * that loses its number when it empties makes the ladder jump about as + * the day goes on. + */ + count?: number | undefined; + /** + * Draws a hairline before this tab, dividing the track into groups. + * + * Sales needs it: Orders and Deliveries are two halves of the app's own + * trade, and Counter sales is a different channel entirely. The rule says so + * without spending a tab on a heading. + */ + isGroupStart?: boolean; + /** + * A pulsing dot after the label, for a view that updates by itself. + * + * Dispatch's Active Fleet is the one: it reads rider positions live, and the + * dot is how the board says so. A named prop rather than a general-purpose + * slot — a `trailing` that took any node would be the seam this component + * exists to close, and there is exactly one live view in the console. + */ + isLive?: boolean; +} + +export function Tab({ label, icon, isActive, onClick, count, isGroupStart, isLive }: TabProps) { + return ( + + ); +} diff --git a/src/components/TablePager.tsx b/src/components/TablePager.tsx new file mode 100644 index 0000000..7d723ce --- /dev/null +++ b/src/components/TablePager.tsx @@ -0,0 +1,49 @@ +import { Pagination } from '@astryxdesign/core/Pagination'; +import type { Paged } from './usePaged'; +import { PAGE_SIZES } from './usePaged'; +import './tablePager.css'; + +/** + * The pager that sits under a table. + * + * One component so every table in the console counts, labels and behaves the + * same way, rather than each page inventing its own row of buttons. + * + * ── It hides itself ───────────────────────────────────────────────────────── + * + * Nothing renders while everything fits on one page. That is what makes it safe + * to put under EVERY table, including the ones that usually hold four rows: a + * pager reading "1–4 of 4" next to a dead prev/next pair is noise, and noise + * under every table is worse than no pager at all. It appears exactly when it + * has something to offer. + * + * `variant="count"` — "21–40 of 96" — rather than a strip of page numbers. + * With 25 rows a page a busy day is four pages, and the number an operator + * actually wants is how much is left, not which of four buttons is lit. + */ +export function TablePager({ + paged, + label = 'rows', +}: { + paged: Paged; + /** What is being counted, for the screen-reader label: "orders", "products". */ + label?: string; +}) { + if (paged.totalPages <= 1) return null; + + return ( +
+ +
+ ); +} diff --git a/src/components/TrailMap.tsx b/src/components/TrailMap.tsx new file mode 100644 index 0000000..fc41334 --- /dev/null +++ b/src/components/TrailMap.tsx @@ -0,0 +1,436 @@ +import { useEffect, useRef } from 'react'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import './trailMap.css'; + +/** + * A map, for things that have a position. + * + * ── Why leaflet directly and not react-leaflet ────────────────────────────── + * + * A leaflet map is an imperative object that owns a DOM node and must be torn + * down by hand — `remove()`, or the tile layer keeps fetching and the container + * keeps its `_leaflet_id` and refuses to be reused. React-leaflet wraps that in + * components and adds a second package that has to track React's major version + * forever. The wrapping is about forty lines; it is written here instead. + * + * ── Why the pins are divIcons ─────────────────────────────────────────────── + * + * Leaflet's default marker is a PNG resolved relative to the stylesheet, which + * every bundler rewrites and breaks — the classic "markers are invisible" + * bug, usually patched by re-pointing the icon URLs at a CDN. A `divIcon` is + * markup, so it ships with the bundle, takes the console's brand colour and + * needs no image at all. + */ + +export interface MapTrail { + id: string | number; + label: string; + points: readonly { lat: number; lng: number }[]; + colour: string; + /** + * Drawn thinner and paler, so a focused round reads against the others. + * + * Muted rather than hidden: a rider's route is still context for the rider + * beside them, and hiding it would make two overlapping rounds impossible to + * compare — which is the reason to focus one in the first place. + */ + isMuted?: boolean; +} + +export interface MapPinPopup { + title?: string; + orderId?: string; + customer?: string; + address?: string; + status?: string; + statusColor?: string; + time?: string; + rider?: string; + amount?: string; + step?: number; + subtitle?: string; + items?: { orderid: string; status: string }[]; +} + +export interface MapPin { + id: string | number; + lat: number; + lng: number; + label: string; + /** Shown under the label in the popup. Plain text, one line per entry. */ + lines?: string[]; + popup?: MapPinPopup; + popupHtml?: string; + colour?: string; + /** A hollow ring rather than a filled pin — for a last-known, not-live point. */ + isFaded?: boolean; + /** + * A square marks a place rather than an event — a shop, a depot. Round pins + * are the things that happened there, so a branch never reads as one more + * customer among its own drops. + */ + shape?: 'round' | 'square'; + /** + * The stop's place in the round, drawn inside the pin. + * + * Without it a route is a line through identical dots and there is no way to + * read which end it started from — the map says "this is the order it was + * worked" and then withholds the order. The console this replaces numbers + * every drop for the same reason. + */ + step?: number; + /** Ringed as the round's first stop. */ + isStart?: boolean; + /** + * Dim this pin. Used when another round is focused, so the one being read + * stands out without the rest disappearing — a rider's stops are still + * context for the rider beside them. + */ + isMuted?: boolean; +} + +/** Tamil Nadu, so an empty map still shows the right part of the world. */ +const FALLBACK: L.LatLngExpression = [11.0168, 76.9558]; + +export function TrailMap({ + trails = [], + pins = [], + height = 380, + emptyNote = 'Nothing to place on the map yet.', +}: { + trails?: readonly MapTrail[]; + pins?: readonly MapPin[]; + height?: number; + emptyNote?: string; +}) { + const host = useRef(null); + const map = useRef(null); + /* Everything drawn, kept together so a redraw clears exactly what it drew. + Clearing the map wholesale would take the tile layer with it. */ + const drawn = useRef(null); + + const isEmpty = trails.every((trail) => trail.points.length === 0) && pins.length === 0; + + useEffect(() => { + if (!host.current || map.current) return; + const instance = L.map(host.current, { + center: FALLBACK, + zoom: 12, + // The console scrolls; a wheel over the map should scroll the page, not + // zoom. Ctrl+wheel and the +/− buttons still zoom, which is what people + // expect from a map embedded in a document. + scrollWheelZoom: false, + attributionControl: true, + }); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }).addTo(instance); + drawn.current = L.layerGroup().addTo(instance); + map.current = instance; + + return () => { + instance.remove(); + map.current = null; + drawn.current = null; + }; + }, []); + + useEffect(() => { + const instance = map.current; + const layer = drawn.current; + if (!instance || !layer) return; + + layer.clearLayers(); + const bounds = L.latLngBounds([]); + + for (const trail of trails) { + if (trail.points.length < 2) continue; + const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]); + + /* A white casing under the coloured line. + Map tiles are busy and mid-toned, and a 3px stroke of any colour + disappears into a main road drawn underneath it. The casing gives the + line its own edge so it reads as something laid ON the map rather than + part of it. Borrowed from the console this replaces, which draws the + same pair — it is the one thing that made its routes legible. */ + L.polyline(line, { + color: '#ffffff', + weight: 7, + // Muted with its line, or a dimmed route shows a bright white casing + // and ends up louder than the one being focused. + opacity: trail.isMuted ? 0.2 : 0.65, + lineJoin: 'round', + lineCap: 'round', + interactive: false, + }).addTo(layer); + + L.polyline(line, { + color: trail.colour, + weight: trail.isMuted ? 2.5 : 3.5, + opacity: trail.isMuted ? 0.35 : 0.95, + // Rounded joins, or a dense trail draws spikes at every turn. + lineJoin: 'round', + lineCap: 'round', + }) + .bindTooltip(trail.label, { sticky: true }) + .addTo(layer); + + line.forEach((point) => bounds.extend(point)); + } + + for (const pin of pins) { + const colour = pin.colour ?? 'var(--color-brand)'; + L.marker([pin.lat, pin.lng], { + title: pin.label, + icon: L.divIcon({ + className: 'trail-pin-wrap', + // A numbered pin has to be big enough to hold two digits legibly, so + // the size follows the content rather than being fixed. + html: + `` + + `${pin.step ? escapeHtml(String(pin.step)) : ''}`, + iconSize: pin.step ? [22, 22] : [16, 16], + iconAnchor: pin.step ? [11, 11] : [8, 8], + }), + }) + .bindPopup(pin.popupHtml ?? formatPinPopup(pin), { + className: 'trail-leaflet-popup-card', + closeButton: true, + maxWidth: 290, + minWidth: 230, + autoPanPadding: [20, 20], + }) + .addTo(layer); + bounds.extend([pin.lat, pin.lng]); + } + + if (bounds.isValid()) { + // `maxZoom` matters: a single pin, or a rider who never left one street, + // otherwise zooms to building level and the map shows one grey rectangle + // with no landmarks to orient by. + instance.fitBounds(bounds, { padding: [28, 28], maxZoom: 16 }); + } + }, [trails, pins]); + + /* Leaflet measures its container once, at construction. Inside a drawer or a + tab the container is often zero-height at that moment, and the map renders + as a grey strip with one tile in the corner until something resizes the + window. Re-measuring whenever the box changes size fixes it for good, + including when the drawer animates open. */ + useEffect(() => { + const node = host.current; + const instance = map.current; + if (!node || !instance || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => instance.invalidateSize()); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + return ( +
+
+ {isEmpty ?
{emptyNote}
: null} +
+ ); +} + +/** + * Distinct, legible line colours. + * + * Hand-picked rather than generated from a hue wheel: evenly spaced hues put + * two yellows next to each other on an OSM tile and both vanish. These are all + * dark enough to read over map detail and different enough to tell apart at the + * width of a polyline. + */ +export const TRAIL_COLOURS = [ + '#662582', + '#0f8a5f', + '#c2410c', + '#1d4ed8', + '#b91c1c', + '#0e7490', + '#7c2d12', + '#4d7c0f', +] as const; + +export function trailColour(index: number): string { + return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string; +} + +function formatPinPopup(pin: MapPin): string { + const p = pin.popup; + if (p) { + if (p.items && p.items.length > 1) { + return ` +
+
+
+ 📦 + ${p.items.length} Orders Here +
+ ${p.step ? `Stop #${escapeHtml(String(p.step))}` : ''} +
+
+
+
🏢
+
+ ${escapeHtml(p.customer || 'Multiple Drops')} + ${escapeHtml(p.address || '')} +
+
+
+ ${p.items.slice(0, 4).map(item => ` +
+ #${escapeHtml(item.orderid)} + ${escapeHtml(item.status)} +
+ `).join('')} + ${p.items.length > 4 ? `
+${p.items.length - 4} more orders
` : ''} +
+
+ +
+ `; + } + + if (p.orderId) { + return ` +
+
+
+ 📦 + #${escapeHtml(p.orderId)} +
+ ${p.step ? `Stop #${escapeHtml(String(p.step))}` : ''} +
+ +
+
+
${escapeHtml((p.customer || 'C').charAt(0).toUpperCase())}
+
+ ${escapeHtml(p.customer || 'Customer')} + ${escapeHtml(p.address || 'Delivery Address')} +
+
+ +
+ + + ${escapeHtml(p.status || 'Pending')} + ${p.time ? `· ${escapeHtml(p.time)}` : ''} + +
+
+ + +
+ `; + } + + const isStoreHub = p.subtitle?.includes('Hub') || p.title?.includes('Hub') || p.title?.includes('R mart') || pin.shape === 'square'; + + if (isStoreHub) { + return ` +
+
+
+ 🏪 + Store Hub +
+ Origin +
+ +
+
+
🏪
+
+ ${escapeHtml(p.title || 'R mart')} + ${escapeHtml(p.subtitle || 'Fulfillment & Dispatch Hub')} +
+
+ +
+ 📍 + ${escapeHtml(p.address || 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002')} +
+ +
+ + + ${escapeHtml(p.status || 'Active Origin Depot')} + +
+
+
+ `; + } + + return ` +
+
+
+ 🛵 + ${escapeHtml(p.title || pin.label)} +
+
+
+ ${p.subtitle ? `
${escapeHtml(p.subtitle)}
` : ''} + ${p.address ? `
${escapeHtml(p.address)}
` : ''} + ${p.status ? ` +
+ + + ${escapeHtml(p.status)} + ${p.time ? `· ${escapeHtml(p.time)}` : ''} + +
+ ` : ''} +
+
+ `; + } + + // Fallback for default pins + return ` +
+
+ ${escapeHtml(pin.label)} +
+
+ ${(pin.lines ?? []).map(line => `
${escapeHtml(line)}
`).join('')} +
+
+ `; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Popup and icon HTML is a string, so a rider named `` must not become markup. */ +function escapeAttr(value: string): string { + return value.replace(/["'<>]/g, ''); +} diff --git a/src/components/kpiCard.css b/src/components/kpiCard.css new file mode 100644 index 0000000..9cd2a0b --- /dev/null +++ b/src/components/kpiCard.css @@ -0,0 +1,140 @@ +/* ══ The KPI tile ══════════════════════════════════════════════════════════ + The console's headline figure, and the card design the rest of the site + follows: the same surface tokens, the same icon tile, the same + label / value / note hierarchy. + + Layout is a stack: a top row carrying the label and the icon tile, then the + figure at full card width, then the note. + + The figure is LEFT-aligned and owns the whole width, and both halves of that + matter. The old tile centred it, so the number sat in a different place in + every card depending on how many digits it had and a row of tiles never + lined up. The tile before this one put the icon in a column to the left, + which left about 134px for the figure at the width these actually get — and + "₹1,24,500" wrapped mid-number. */ + +.kpi-card { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + /* A floor, not a height. Labels wrap to two lines at narrow widths — with + Nearle Buddy open these are about 195px wide, and "Cancelled orders" + ellipsised to "Cance…" is a label that has stopped working — so the tile + grows and the row stays level. */ + min-height: 104px; + padding: 16px; + + /* The console's one card surface. Same tokens as `Panel` and every other + card in the app; see the note in `index.css`. */ + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); + box-shadow: var(--card-shadow); +} + +/* ── The icon tile ────────────────────────────────────────────────────────── + A rounded square in the tone's tint, one step tighter than the card that + holds it — a control radius inside a card radius, which is what keeps it + from reading as a small card of its own. */ +.kpi-card-icon { + width: 40px; + height: 40px; + flex: none; + display: grid; + place-items: center; + border-radius: var(--card-radius-sm); + background: var(--kpi-tint); + color: var(--kpi-ink); +} + +/* Brand by default — a tile that is merely reporting should not shout. The + other three are for tiles whose figure IS a state. */ +.kpi-card { + --kpi-ink: var(--color-brand); + --kpi-tint: var(--color-brand-tint); +} +.kpi-card[data-tone='success'] { + --kpi-ink: var(--color-success, #1f9d55); + --kpi-tint: color-mix(in oklab, var(--color-success, #1f9d55) 12%, transparent); +} +.kpi-card[data-tone='warning'] { + --kpi-ink: var(--color-warning, #b7860b); + --kpi-tint: color-mix(in oklab, var(--color-warning, #b7860b) 14%, transparent); +} +.kpi-card[data-tone='error'] { + --kpi-ink: var(--color-error, #d64545); + --kpi-tint: color-mix(in oklab, var(--color-error, #d64545) 12%, transparent); +} + +/* ── The top row ────────────────────────────────────────────────────────── */ +/* Label left, icon right. The label takes the space the icon does not, and the + figure below gets the whole card. */ +.kpi-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-width: 0; + margin-bottom: 8px; +} + +/* The label names the figure and stays quiet doing it. Not caps: at 11px in + caps with letter-spacing, "Cancelled orders" runs wider than the tile and a + two-line eyebrow reads as a heading that broke. Sentence case, ink-3. */ +.kpi-card-label { + font: 500 12.5px/1.3 var(--font-sans); + color: var(--color-ink-3); + min-width: 0; + /* Two lines, then ellipsis — never a third. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* The figure. Tabular so a column of tiles lines up on the digits, and pulled + in very slightly — at 25px the default tracking makes a rupee figure look + spaced out rather than set. + + IT NEVER WRAPS. `overflow-wrap: anywhere` was on this rule and it broke + "₹1,24,500" across two lines between the 0 and the 0 — a figure split + mid-digit is not a smaller figure, it is a wrong one. A number too wide for + its tile ellipsises instead, which is visibly truncated rather than quietly + misread, and the `title` is not needed because the same figure is on the + page it links to. */ +.kpi-card-value { + font: 700 25px/1.2 var(--font-sans); + letter-spacing: -0.02em; + color: var(--color-ink-1); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* What the figure is made of. This is the line that makes the number mean + something — see the note in `KpiCard.tsx` for why it had to come back. */ +.kpi-card-note { + margin-top: 3px; + font: 400 11.5px/1.4 var(--font-sans); + color: var(--color-ink-4); + /* Two lines at most. "142 app · 312 counter" wraps at tile width and that is + fine; a third line would push the tile past its neighbours. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* `.kpi-grid` is NOT defined here. It already exists in `index.css`, built on + container queries rather than viewport ones — which is what lets a tile row + reflow when Nearle Buddy opens beside it and takes 340px out of the column, + something a viewport breakpoint cannot see. Defining a second one here would + have won on import order and silently thrown that away. */ + +@media (max-width: 600px) { + .kpi-card { min-height: 96px; padding: 14px; } + .kpi-card-icon { width: 32px; height: 32px; } + .kpi-card-value { font-size: 22px; } +} diff --git a/src/components/panel.css b/src/components/panel.css new file mode 100644 index 0000000..fffba0f --- /dev/null +++ b/src/components/panel.css @@ -0,0 +1,141 @@ +/* ══ The panel — the console's one card ════════════════════════════════════ + Three zones: a header band that says what this is and how much of it there + is, a body, and a footer band that says where you are in it. See the note at + the top of `Panel.tsx` for why the card grew an anatomy rather than a nicer + outline. + + Every size here comes from the card tokens in `index.css`. */ + +.panel-card { + display: flex; + flex-direction: column; + min-width: 0; + border: var(--card-border); + border-radius: var(--card-radius); + background: var(--card-bg); + box-shadow: var(--card-shadow); + /* The clip belongs on the element that owns the radius, or the header band's + tint squares off the top two corners. */ + overflow: hidden; +} + +/* For a grid row of panels that should finish level with each other. */ +.panel-card[data-filled='true'] { height: 100%; } + +/* ── Header band ────────────────────────────────────────────────────────── */ +/* Tinted rather than white, so the band reads as chrome and the rows below it + read as data. A white header separated only by a rule is the same surface + twice, which is how a heading ends up looking like a first table row. */ +.panel-card-head { + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + min-height: 48px; + padding: 8px 16px; + background: var(--color-surface-subtle); + border-bottom: var(--card-border); +} + +.panel-card-title-group { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +/* An EYEBROW, not a heading. 11px caps: this labels the panel, and a 20px + title inside a card competes with the page's own title above it — the two + stacked is what made every screen say its subject twice. */ +.panel-card-title { + margin: 0; + font: 600 11px/1.2 var(--font-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-ink-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* The count, in its own chip. Tabular, because it changes as a filter narrows + and a figure that shifts sideways while you read it is hard to trust. */ +.panel-card-count { + display: inline-flex; + align-items: baseline; + gap: 4px; + flex: none; + padding: 2px 8px; + border-radius: 999px; + background: var(--color-surface-sunken); + font: 600 11.5px/1.45 var(--font-sans); + font-variant-numeric: tabular-nums; + color: var(--color-ink-2); +} +.panel-card-count-label { + font-weight: 400; + font-size: 11px; + color: var(--color-ink-4); +} + +.panel-card-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-left: auto; +} + +/* ── Body ───────────────────────────────────────────────────────────────── */ + +.panel-card-body { + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} +.panel-card-body[data-pad='md'] { padding: 16px; } + +/* ── Footer band ────────────────────────────────────────────────────────── */ + +.panel-card-foot { + flex: none; + padding: 8px 16px; + background: var(--color-surface-subtle); + border-top: var(--card-border); +} + +/* The pager hides itself on a single page — it returns null — but the band + around it is rendered by the parent, which cannot see that. Without this a + short table carried a 44px strip of tinted chrome saying nothing. + `:has` collapses the band when its child rendered no DOM at all. */ +.panel-card-foot:not(:has(*)) { + display: none; +} + +/* The pager's own wrapper adds padding meant for sitting loose under a table. + Inside the band the band supplies it. */ +.panel-card-foot .table-pager { + padding: 0; + border-top: 0; +} + +/* ── A table sitting flush in the body ────────────────────────────────────── + The table brings its own horizontal scroll and its own header row. What it + must not bring is a rounded corner or an outer border — the panel owns both, + and a table with its own edge inside a panel is the double line this design + is meant to remove. */ +.panel-card-body > .table-scroll { + border-radius: 0; + border: 0; +} + +/* ── Narrow ─────────────────────────────────────────────────────────────── */ +@media (max-width: 600px) { + .panel-card-head { padding: 8px 12px; } + .panel-card-foot { padding: 8px 12px; } + .panel-card-body[data-pad='md'] { padding: 12px; } + /* Actions drop to their own line rather than squeezing the title out. */ + .panel-card-actions { margin-left: 0; width: 100%; } +} diff --git a/src/components/searchInput.css b/src/components/searchInput.css new file mode 100644 index 0000000..14bd460 --- /dev/null +++ b/src/components/searchInput.css @@ -0,0 +1,82 @@ +/* ══ Search box ════════════════════════════════════════════════════════════ + Shaped to match the view-tab track it sits opposite — see the note in + `SearchInput.tsx`. Every number here is the same number `tabBar.css` uses. */ + +.searchbox { + display: inline-flex; + align-items: center; + gap: 7px; + /* The track's height and the track's corner. This is the whole point of the + component: a search that is visibly the same object as the buttons at the + other end of the row. */ + height: 38px; + padding: 0 10px; + border-radius: var(--card-radius-sm); + border: var(--card-border); + background: var(--card-bg); + box-shadow: var(--card-shadow); + max-width: 100%; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +/* Fills its container, for a box sitting directly above the list it filters. */ +.searchbox[data-full='true'] { display: flex; width: 100%; } + +.searchbox:hover { border-color: var(--color-ink-4); } + +/* Focus lands on the BOX, not on the bare input inside it — the input has no + border of its own, so the default ring would draw inside the box and read as + a second, smaller field. */ +.searchbox:focus-within { + border-color: var(--color-brand); + box-shadow: 0 0 0 3px var(--color-brand-tint); +} + +.searchbox-icon { flex: none; color: var(--color-ink-4); } +.searchbox:focus-within .searchbox-icon { color: var(--color-brand); } + +.searchbox-input { + flex: 1 1 auto; + min-width: 0; + border: 0; + padding: 0; + background: transparent; + color: var(--color-ink-1); + font: 400 13px/1 var(--font-sans); + outline: none; + /* Safari draws its own rounded field and inner shadow on a text input, and + `type="search"` adds a magnifier and a cancel button on top of ours. */ + appearance: none; + -webkit-appearance: none; +} +.searchbox-input::placeholder { color: var(--color-ink-4); } +.searchbox-input::-webkit-search-decoration, +.searchbox-input::-webkit-search-cancel-button, +.searchbox-input::-webkit-search-results-button { + appearance: none; + -webkit-appearance: none; + display: none; +} + +.searchbox-clear { + flex: none; + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 999px; + background: var(--color-surface-sunken); + color: var(--color-ink-3); + cursor: pointer; + transition: background 140ms ease, color 140ms ease; +} +.searchbox-clear:hover { background: var(--color-line); color: var(--color-ink-1); } +.searchbox-clear:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; } + +/* On a phone the header row wraps, and a 260px box beside a scrolling tab + track leaves neither enough room. It takes the full line instead. */ +@media (max-width: 600px) { + .searchbox { width: 100% !important; } +} diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx new file mode 100644 index 0000000..fd153b0 --- /dev/null +++ b/src/components/shell/AppShell.tsx @@ -0,0 +1,824 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { ChevronDown, ChevronLeft, LogOut, Menu, X } from 'lucide-react'; +import { useIsMobile } from '@/hooks/useIsMobile'; +import { useAuth } from '@/auth/AuthContext'; +import { ROLE_LABEL } from '@/auth/roles'; +import { AssistantPanel } from './AssistantPanel'; +import { DateScopePicker } from './DateScope'; + +export interface NavEntry { + to: string; + label: string; +} + +/** One entry in the account menu's MANAGE group. */ +export interface MenuEntry { + to: string; + label: string; + icon: ReactNode; + /** One line under the label, for an entry whose scope is not obvious. */ + note?: string; +} + +export interface AppShellProps { + /** Destinations for the header tabs and the mobile sheet. */ + nav: readonly NavEntry[]; + /** Where the logo links to — the workspace's own landing page. */ + home: string; + /** Accessible name for the tab list, e.g. "Store Admin". */ + navLabel: string; + /** + * An optional control between the logo and the tabs — the Store Admin's + * branch selector lives here. It sits inside the header rather than on each + * page because it scopes every page, and a control that moves between pages + * reads as a different control each time. + */ + scopeControl?: ReactNode; + /** + * Setup destinations, listed inside the account menu rather than in the nav. + * + * This follows the old console's judgement, and its reasoning holds: setup is + * configuration, not a place anyone works from day to day, and a nav slot + * spent on it is a slot taken from a section that IS worked from. The account + * menu is where "my workspace's setup" belongs, beside the account and + * sign-out. + * + * Listed as individual destinations rather than one "Settings" entry for the + * old console's other reason: a single entry lands everyone on the first + * section and makes them click again, and the sections are what people come + * here for. + */ + manageItems?: readonly MenuEntry[]; + /** + * Workspace-specific controls in the header, left of the notification bell. + * + * For things a shop reaches from anywhere and that open in place rather than + * navigating — the store's QR code is the first. A page for it would have + * been a fifth destination for something that is looked at, printed once, and + * closed. + */ + headerActions?: ReactNode; + /** + * A full-width strip between the header and the page. + * + * The setup walkthrough lives here. It has to sit in the shell rather than + * on a page because it crosses several: one step is on Profile, the next on + * Users, the next on Inventory — anything page-local would vanish the moment + * somebody followed it. + */ + banner?: ReactNode; +} + +/** + * The application chrome, shared by every workspace. + * + * Built to KROW's `AdminLayout` spec. Deliberately NOT a coloured slab: the + * header is a 56px white-at-85% bar with a backdrop blur and a hairline bottom + * border, carrying text tabs whose active state is accent-coloured text plus a + * 2px accent bar sitting on that border. The repo's own reasoning for text tabs + * over pills: several destinations means several competing shapes if each one + * is a pill, and a console header should recede rather than compete with the + * page. + * + * One component rather than one per workspace. The workspaces differ in exactly + * three things — their destinations, their home, and whether they have a scope + * control — so those are props. Copying four hundred lines of chrome per role + * is how two headers drift apart and stop looking like one product. + */ +export function AppShell({ + nav, + home, + navLabel, + scopeControl, + manageItems, + headerActions, + banner, +}: AppShellProps) { + const { user, signOut } = useAuth(); + const { pathname } = useLocation(); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isAssistantOpen, setIsAssistantOpen] = useState(true); + const [isNavOpen, setIsNavOpen] = useState(false); + const isMobile = useIsMobile(); + const menuRef = useRef(null); + + /* + A new page starts at the top. + + A single-page app does not reload, so the document keeps whatever scroll + offset the last page was left at: read Sales down to row 40, click Inventory, + and Inventory opens 2,000px down — usually past everything it has, so it + looks empty. Nothing in the app was resetting this. + + INSTANT, deliberately, and it is the one place in the console that opts out + of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would + animate those 2,000px on every navigation, which means a second of the new + page flying past before it settles — the page arriving late, rather than the + page arriving. Smooth is for a move you asked for within a page; a route + change is not one. + + Keyed on `pathname` alone, not on the whole location: the branch scope and + the date range travel in the query string, and re-scoping a table you are + halfway down should leave you where you were. + */ + useEffect(() => { + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + }, [pathname]); + + // Escape closes the account menu. A menu that can only be dismissed by + // finding the trigger again is a trap for anyone on a keyboard, and this one + // sits over the page rather than beside it. + useEffect(() => { + if (!isMenuOpen) return; + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setIsMenuOpen(false); + } + function onMouseDown(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsMenuOpen(false); + } + } + window.addEventListener('keydown', onKeyDown); + document.addEventListener('mousedown', onMouseDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + document.removeEventListener('mousedown', onMouseDown); + }; + }, [isMenuOpen]); + + return ( +
+ {/* Two elements, on purpose. The bar is full-bleed — the glass, the blur + and the hairline run the whole width of the screen, because a header + that stops short of the edge reads as a floating card, not as chrome. + The ROW inside it is capped by `.app-gutter`, so the logo and nav sit + on exactly the same left edge as the page title below them at every + width, including a 2560px monitor where the body is centred. */} + {/* + Opaque, with no backdrop blur. + + The blur cost more than it bought the moment a popover moved into this + bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every + `position: fixed` descendant, and the design system's popovers are fixed + and CSS-anchor-positioned — so the date picker's calendar rendered + inside the header at 0×0 and could not be opened at all. The same + control worked perfectly two pixels lower, on the page. + + A solid background is the fix rather than a hack around it: the bar sits + on a near-white page, so at 85% opacity plus blur it was already almost + opaque, and nothing here reads differently for losing it. + */} +
+
+ + {/* 24px tall, width auto — the reference pins the logo's height and + lets the wordmark set its own width. */} + Nearle + + + + {/* Below md the scope control moves into the navigation sheet. On a + 390px phone the logo, the selector and the right-hand cluster add + up to 503px and push the header 113px past the viewport, so the + page scrolls sideways. The sheet gives the branch names room to be + read in full, and every page states its own scope in the header + line beneath, so the phone never leaves the operator guessing. */} + {scopeControl ? ( +
+ {scopeControl} +
+ ) : null} + + {/* Below lg the tabs are gone, so a flexible spacer keeps the right + cluster against the edge instead of bunched beside the logo. */} +
+ + {/* Text tabs. Active = accent text + a 2px accent bar on the header rule. */} + + +
+ {/* No global search. + + There was a search box here with `⌘K` on it, and its `onSubmit` + was `preventDefault()` and nothing else — it advertised a + console-wide search that did not exist and had no endpoint + behind it. The per-page search boxes on Sales, Users, Stores and + the catalogue are real and stay. A control that looks like it + works costs more trust than a missing one. */} + + {/* The date filter, beside the profile and common to every page. + + Rendered here rather than by each page so the two questions the + console asks — which shop, and when — are both answered in the + chrome, and so the answer survives navigation. */} + + + {headerActions} + + {/* No notification bell. It was labelled "2 unread" with the dot + painted unconditionally, for every user on every page, forever — + and it had no click handler and no notifications endpoint behind + it anywhere in the API. */} + + {/* Assistant button moved to floating pill */} + + {/* Avatar trigger + chevron, with the account menu below it. */} +
+ + + {isMenuOpen ? ( +
+
+
+ {user?.name} +
+
{user?.email}
+
+ + {user ? ROLE_LABEL[user.role] : ''} + +
+
+ {manageItems && manageItems.length > 0 ? ( + <> + +

+ Manage +

+ {manageItems.map((entry) => ( + setIsMenuOpen(false)} + /> + ))} + + ) : null} + + + + + {/* When this code was built. If it does not match what you + were told was delivered, you are looking at an old copy — + which is not something any screen otherwise reveals. */} +

+ Build {__BUILD_STAMP__} +

+
+ ) : null} +
+ + {/* Wrapped rather than styled inline: an inline `display` would win + over the media query that hides this above the tabs breakpoint. */} + + setIsNavOpen(true)}> + + + +
+
+
+ + {/* Body: a column on a phone so the assistant stacks under the page, a + row from md where it becomes a side column. */} + {banner} + +
+
+ {/* Scoped to the page, not the shell: a page that throws should leave + the nav, the account menu and the workspace switcher usable, so + you can walk to a screen that works instead of reloading blind. + Keyed by pathname so navigating away clears a caught error — + without that, one broken page latches the whole outlet. */} + + + +
+ + {isAssistantOpen || isMobile ? ( + setIsAssistantOpen(false)} isStacked={isMobile} /> + ) : null} +
+ + {/* Floating Assistant Button */} + {!isMobile && !isAssistantOpen ? ( + + ) : null} + + {isNavOpen ? ( + setIsNavOpen(false)} + pathname={pathname} + /> + ) : null} +
+ ); +} + +/** + * The navigation sheet, below lg. + * + * 288px from the right, opaque rather than glass — a translucent sheet over a + * page of tables is unreadable — with the active item carried by a tinted fill + * instead of the 2px underline, which has nothing to sit on here. + */ +function MobileNav({ + nav, + scopeControl, + onClose, + pathname, +}: { + nav: readonly NavEntry[]; + scopeControl?: ReactNode; + onClose: () => void; + pathname: string; +}) { + return ( +
+
+ ); +} + +/** + * One destination in the account menu. + * + * A `NavLink` rather than a button with `navigate()`: middle-click, ⌘-click and + * "open in new tab" all work on a real anchor and none of them work on a + * button, and a setup screen is exactly the kind of thing someone parks in a + * second tab. + */ +function MenuLink({ entry, onNavigate }: { entry: MenuEntry; onNavigate: () => void }) { + const [isHovered, setIsHovered] = useState(false); + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '8px 12px', + borderRadius: 12, + textDecoration: 'none', + background: isHovered ? 'var(--color-surface-sunken)' : 'transparent', + color: 'var(--color-ink-1)', + transition: 'background .15s', + }} + > + + {entry.icon} + + + {entry.label} + {entry.note ? ( + + {entry.note} + + ) : null} + + + ); +} + +const Rule = () => ( +
+); + +/** + * Up to two letters for the avatar. Never a dash. + * + * First name and last name where the account has them. Where it does not — + * plenty of rows on this backend carry neither — the email is used instead, + * split on the separators people actually put in addresses, so + * `ragul.kumar@shop.in` gives RK and `care@nearle.in` gives C. + * + * The old version returned an em dash for anything it could not parse, and that + * is what every Store Admin and Store user saw: their accounts have no first or + * last name, so the avatar was a dash on every page. A dash says nothing and + * looks like a bug, which is worse than one letter. + */ +function initials(name: string): string { + const trimmed = name.trim(); + if (trimmed === '') return '?'; + + // An email is not a name. Take the part before the @ and read the words out + // of it — a full address would otherwise give the domain's letter as the + // second initial. + const source = trimmed.includes('@') ? (trimmed.split('@')[0] ?? trimmed) : trimmed; + + const words = source + .split(/[\s._\-+]+/) + .map((word) => word.replace(/[^\p{L}\p{N}]/gu, '')) + .filter(Boolean); + + if (words.length === 0) return '?'; + + const first = words[0]?.[0] ?? ''; + const last = words.length > 1 ? (words[words.length - 1]?.[0] ?? '') : ''; + return (first + last).toUpperCase(); +} + +/** + * A 32px icon-only control. + * + * `label` is required, not optional — an icon-only control with no accessible + * name is a bug, so the API makes it impossible to omit. + */ +export function IconButton({ + label, + children, + onClick, + hasUnread, +}: { + label: string; + children: ReactNode; + onClick?: () => void; + hasUnread?: boolean; +}) { + const [isHovered, setIsHovered] = useState(false); + return ( + + ); +} diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx new file mode 100644 index 0000000..b7e8c93 --- /dev/null +++ b/src/components/shell/AssistantPanel.tsx @@ -0,0 +1,1009 @@ +import { Fragment, useEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { errorMessage } from '@/api/client'; +import { + approveAssistant, + askAssistant, + assistantAvailable, + type AssistantAnswer, +} from '@/api/assistant'; +import { useAssistantScope } from './assistantScope'; +import { matchAssistantRoute } from './assistantContext'; +import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react'; +import { + DEFAULT_WIDTH, + expandedWidth, + MAX_WIDTH, + MIN_WIDTH, + useAssistantWidth, +} from './assistantWidth'; + +/** One question and what came back. `answer` and `error` are exclusive. */ +interface Exchange { + question: string; + answer?: AssistantAnswer; + error?: string; + /** + * What became of a change Buddy proposed. + * + * `undefined` means the card is still on screen waiting. Once decided the + * card is replaced by its outcome and cannot be pressed again — a card that + * stayed live after approval is a second write waiting to happen. + */ + decision?: { state: 'approving' | 'done' | 'dismissed' | 'failed'; message?: string }; +} + +/** Replaces the last entry, which is always the one in flight. */ +function replaceLast(thread: Exchange[], update: (entry: Exchange) => Exchange): Exchange[] { + if (thread.length === 0) return thread; + return thread.map((entry, index) => (index === thread.length - 1 ? update(entry) : entry)); +} + +/** + * What the composer says when it cannot be used, and why. + * + * Three different states with three different sentences. "Not connected yet" + * for everything would be true and useless: a person whose deployment has no + * model can do nothing, but a person on the Inventory page can move to Sales + * and get an answer today. + */ +export function composerHint( + available: boolean | null, + hasAgent: boolean, + isAsking: boolean, +): string { + if (isAsking) return 'Thinking…'; + if (available === null) return 'Checking…'; + if (available === false) return 'Not connected yet'; + if (!hasAgent) return 'No assistant for this page yet'; + return 'Ask about this page'; +} + +/** + * The line under the composer, or nothing. + * + * This used to be one hardcoded sentence — "Not connected yet — there is no + * assistant service behind this panel." — written before the panel had an API + * behind it and never removed once it did. It rendered on every page in every + * state, so the panel told everybody Buddy was off, permanently, including on a + * deployment where Buddy was answering questions. It also flatly contradicted + * the placeholder three lines above it, which was the only part telling the + * truth. + * + * The lesson worth keeping: a status message with no condition attached is not + * a status message. Each branch here reads the same two facts the composer + * itself is disabled by, so the two cannot drift apart again. + */ +export function composerNote(available: boolean | null, hasAgent: boolean): string { + // Still asking. A sentence that appears for 200ms and is replaced reads as a + // flicker, so the space stays empty until there is something true to put in it. + if (available === null) return ''; + if (available === false) return 'Not connected yet — there is no assistant service behind this panel.'; + if (!hasAgent) return 'No assistant for this page yet. Try Console or Sales.'; + // Buddy is working. The caveat that matters then is not about connection: it + // is that an answer is a model reading live rows, and the rows are the thing + // to act on. + return 'Buddy reads your live data. Check anything you act on.'; +} + +/** + * A change waiting on the person. + * + * Framed and set apart from the reply on purpose. The sentence above it was + * written by a model; this was resolved by the server against the database, and + * the two must not read as one thing. What is shown here — the ids, the names, + * the quantity — is what the button actually agrees to. + * + * Once decided the buttons are gone, replaced by what happened. A card that + * stayed pressable after approval is a second write waiting for a double-click. + */ +function ApprovalCard({ + proposal, + decision, + onDecide, +}: { + proposal: NonNullable; + decision: Exchange['decision']; + onDecide: (approve: boolean) => void; +}) { + const busy = decision?.state === 'approving'; + + const outcomeColour = + decision?.state === 'failed' + ? 'var(--color-error, #d64545)' + : decision?.state === 'done' + ? 'var(--color-success, #1f9d55)' + : 'var(--color-ink-3)'; + + return ( +
+

+ {proposal.summary} +

+ + {proposal.details?.length ? ( +
+ {proposal.details.map((detail) => ( + +
{detail.label}
+
+ {detail.value} +
+
+ ))} +
+ ) : null} + + {proposal.warning ? ( +

+ {proposal.warning} +

+ ) : null} + + {decision === undefined ? ( +
+ + {/* "Not now", not "Reject". Nothing is refused and nothing is + recorded — the card is simply left alone, and it expires unused. */} + +
+ ) : ( +

+ {busy + ? 'Making the change…' + : decision.state === 'dismissed' + ? 'Left alone. Nothing was changed.' + : (decision.message ?? (decision.state === 'done' ? 'Done.' : 'That did not go through.'))} +

+ )} +
+ ); +} + +/** + * One question and its answer. + * + * The answer shows what it ran, not just what it concluded. Buddy states things + * with the confidence of a sentence, and the only honest way to present that is + * beside the tools it used and a link to the page holding the same rows — so a + * person can disagree with it. + */ +function Exchange({ + entry, + isLast, + isAsking, + onDecide, +}: { + entry: Exchange; + isLast: boolean; + isAsking: boolean; + onDecide: (approve: boolean) => void; +}) { + const waiting = isLast && isAsking && !entry.answer && !entry.error; + + return ( +
+

+ {entry.question} +

+ + {waiting ? ( +

Looking…

+ ) : null} + + {entry.error ? ( +

+ {entry.error} +

+ ) : null} + + {entry.answer ? ( +
+

+ {entry.answer.reply} +

+ + {/* Said plainly, not as a subtle grey hint. A partial answer that + looks complete is the failure this flag exists to prevent. */} + {entry.answer.incomplete ? ( +

+ This answer is partial — Buddy ran out of room before finishing. +

+ ) : null} + + {entry.answer.used?.length ? ( +

+ {entry.answer.used + .map((step) => + step.outcome === 'ok' + ? `${step.tool}${typeof step.rows === 'number' ? ` · ${step.rows}` : ''}` + : `${step.tool} · refused`, + ) + .join(' ')} +

+ ) : null} + + {entry.answer.awaiting ? ( + + ) : null} + + {entry.answer.sources?.length ? ( +

+ {entry.answer.sources.map((source) => ( + + See the rows + + ))} +

+ ) : null} +
+ ) : null} +
+ ); +} + +/** + * Typed against `CONTEXT`'s own shape so the two cannot drift. + * + * Without this the fallback has no `agent` key at all, and `routeContext` — + * which is one or the other — loses the field entirely. A page that fell + * through to the fallback would then be a compile error rather than simply a + * page with no assistant, which is what it is. + */ + +/** + * Nearle Buddy, as a layout column. + * + * Not an overlay and not a floating bubble: the panel is part of the page on + * every supported route, so the page reflows beside it rather than being + * covered. It opens by stating the most useful thing about the page it is next + * to, says what it read, and offers prompts scoped to that page. + * + * 380px by default, sticky under the 56px header, and its own height is the + * viewport minus 104px so it never pushes the page taller than the screen. + */ +export function AssistantPanel({ + onClose, + isStacked = false, +}: { + onClose: () => void; + /** Below md the panel sits under the page rather than beside it. */ + isStacked?: boolean; +}) { + const { pathname } = useLocation(); + const [draft, setDraft] = useState(''); + const [isFocused, setIsFocused] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const textareaRef = useRef(null); + const threadRef = useRef(null); + const { width, setWidth, reset } = useAssistantWidth(); + const [isDragging, setDragging] = useState(false); + + const { key, context: routeContext } = matchAssistantRoute(pathname); + + /** + * The heading follows the branch in view. + * + * A panel headed "Across your branches" sitting above a board showing one + * shop is a promise about scope that the page has already broken. Only the + * title moves; everything else about Buddy is untouched. + */ + const scopeLabel = useAssistantScope(); + const context = scopeLabel ? { ...routeContext, title: scopeLabel } : routeContext; + + /* + * Whether Buddy can answer here at all, and why not when it cannot. + * + * Two separate reasons, kept apart because they are two different facts and + * the person can act on one of them. `available === false` means this + * deployment has no model configured — nothing to be done from the browser. + * `agent === undefined` means this page has no assistant yet, which is about + * the page and not the deployment. + * + * `null` is "not asked yet": the composer stays disabled during the check, so + * it is never briefly live against a server that turns out to have no model. + */ + const [available, setAvailable] = useState(null); + const [thread, setThread] = useState([]); + const [isAsking, setAsking] = useState(false); + const agent = routeContext.agent; + const canAsk = available === true && Boolean(agent) && !isAsking; + + useEffect(() => { + let live = true; + void assistantAvailable().then((ok) => { + if (live) setAvailable(ok); + }); + // Cancelled on unmount so a slow answer cannot set state on a closed panel. + return () => { + live = false; + }; + }, []); + + /* + * The thread is per-page and deliberately not persisted. + * + * An answer about Sales sitting above the Inventory page is worse than no + * answer: it reads as being about what is on screen. Clearing on navigation + * costs a person their history, which is the smaller loss. + */ + useEffect(() => { + setThread([]); + }, [key]); + + /* + * Follow the conversation down, unless the person has scrolled away from it. + * + * A chat that does not follow leaves the newest answer below the fold, which + * reads as nothing having happened. One that follows unconditionally yanks + * somebody out of an earlier answer they were still reading the moment a + * reply lands — and a reply can land a while after the question, because a + * tool call and a model round trip are seconds, not milliseconds. + * + * So: only when they were already at the bottom. The 40px allowance covers + * fractional scroll heights, which browsers disagree about by a pixel or two + * at non-integer zoom levels — without it this silently stops following for + * anybody not at 100%. + */ + useEffect(() => { + const el = threadRef.current; + if (!el) return; + + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom > 40) return; + + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + }, [thread]); + + /* + * Approving is its own call, with no question in it. + * + * Indexed rather than acting on the last exchange: a person can scroll up and + * approve an earlier card, and nothing about a card ties it to being the most + * recent thing said. + */ + const decide = async (index: number, approve: boolean) => { + const entry = thread[index]; + const card = entry?.answer?.awaiting?.card; + if (!card || !agent || entry.decision) return; + + if (!approve) { + // Dismissing is purely local: the server was never told, because there is + // nothing to undo. The card simply expires unused. + setThread((current) => + current.map((e, i) => (i === index ? { ...e, decision: { state: 'dismissed' as const } } : e)), + ); + return; + } + + setThread((current) => + current.map((e, i) => (i === index ? { ...e, decision: { state: 'approving' as const } } : e)), + ); + + try { + const done = await approveAssistant(agent, card); + setThread((current) => + current.map((e, i) => + i === index ? { ...e, decision: { state: 'done' as const, message: done.reply } } : e, + ), + ); + } catch (error) { + // "Somebody already approved that" arrives here, and it is an answer + // rather than a fault — shown on the card, which stays decided so the + // button cannot be pressed again into the same refusal. + setThread((current) => + current.map((e, i) => + i === index ? { ...e, decision: { state: 'failed' as const, message: errorMessage(error) } } : e, + ), + ); + } + }; + + const send = async (text: string) => { + const question = text.trim(); + if (!question || !agent || !canAsk) return; + + setDraft(''); + setAsking(true); + setThread((current) => [...current, { question }]); + + try { + const answer = await askAssistant(agent, question); + setThread((current) => replaceLast(current, (entry) => ({ ...entry, answer }))); + } catch (error) { + // Shown in the thread rather than as a toast: the question is still on + // screen, and the failure belongs next to it. + setThread((current) => + replaceLast(current, (entry) => ({ ...entry, error: errorMessage(error) })), + ); + } finally { + setAsking(false); + } + }; + + /** + * Three widths, in priority order: stacked (the phone layout owns it), + * expanded (the one-click half-screen), then whatever the operator dragged + * it to. The inline width beats the media queries in `index.css`, which is + * the point — those are the default, not the rule. + * + * Height is deliberately NOT set here. It was, briefly — measured off the + * page so a short page got a short panel — but that put a strip of page + * background under the panel on every screen that was not full. The column + * is full height, always, and `.assistant` owns that. + */ + const sizeStyle: React.CSSProperties = isStacked + ? {} + : isExpanded + ? { width: expandedWidth(window.innerWidth), maxWidth: 'none' } + : { width, maxWidth: 'none' }; + + return ( + /* No `position` here. `.assistant` is `position: sticky` from md up, and an + inline `relative` silently beat it — which is what made the panel scroll + away with the page. Sticky is itself a containing block, so the resize + handle's `absolute` still anchors to it. */ +