diff --git a/.claude/CONTEXT.md b/.claude/CONTEXT.md index 5afcff8..60928b2 100644 --- a/.claude/CONTEXT.md +++ b/.claude/CONTEXT.md @@ -50,9 +50,24 @@ After all 6 phases were done, a pass comparing the system against the actual exa Snyk: 0 issues. Typecheck and full `bun run test` (all 6 packages) green after both bug fixes. +## Addition: React frontend (branch `feature/frontend`, additive, not graded) + +After backend submission, the user wanted a UI to click-test the system themselves before the oral defense. New top-level `frontend/` (React + Vite + TypeScript, react-router-dom), its own 7th Docker container with a healthcheck, served by a hand-rolled Bun static file server (`frontend/serve.ts` — path-traversal-safe, SPA-fallback for client routes). `nginx.conf` now splits traffic: `/api/*` and `= /health` → gateway (unchanged), everything else → the new `frontend` upstream — nginx remains the system's only published port. + +Key decisions, each one a deliberate fix for a concrete risk surfaced during planning/implementation, not guesswork: +- **Did NOT import `OrderItem` from `@quickbite/shared`** for client-side cart validation — that package's barrel re-exports `mq.ts`/`idempotency.ts`, which pull in `amqplib`/`ioredis` (Node-only, not browser-safe). Duplicated a minimal zod schema in `frontend/src/lib/orderItem.ts` instead, same "client previews, server stays authoritative" reasoning as `cartTotal.ts` vs. `services/order/src/pricing.ts`. +- **`useOrderPolling` hook**: setTimeout-chain (never raw `setInterval`, to avoid overlapping in-flight requests), AbortController + `cancelled` flag (no setState-after-unmount), treats a 404 as "not found yet" within a bounded ~60s window (not fatal — the outbox pattern means the row can briefly lag right after `POST /orders` returns), explicit "timed-out" UI state if `"ready"` is never reached. +- **Real test-infra bug found and fixed**: `@testing-library/react`'s `screen` binds to `document.body` once, at *module-load time* — and ESM hoists all `import` statements above any other code regardless of where they're written. A static `import { cleanup } from "@testing-library/react"` at the top of `frontend/tests/setup.ts`, written after `GlobalRegistrator.register()` in source order, was still being evaluated *before* it at runtime, permanently breaking every `screen.*` query. Fixed with a dynamic `await import("@testing-library/react")` placed after registration. Also needed an `afterEach(cleanup)` — without it, happy-dom's shared document persists rendered output across test files, causing false "multiple elements found" errors. +- Frontend's own `zod` was pinned to `^3.23.8` to match the rest of the repo (it resolved to v4 by default) — same major version everywhere, deliberately. +- Verified live, not just via tests: full `docker compose up --build` → `frontend` reaches `healthy` → curl-driven `placed → accepted → ready` through nginx → confirmed a hard-refresh on a deep link like `/orders/` returns the SPA shell (200), not a 404 → confirmed path-traversal attempts on `serve.ts` fall through safely to the SPA fallback, never leak a file outside `dist/` → confirmed the public-entry-point invariant still holds (only nginx has a published port; frontend's internal port 3005 is unreachable from the host). + +Snyk: 0 issues. Typecheck and full `bun run test` (now 7 packages, frontend included — pure `lib/` unit tests + `happy-dom`-backed component tests) green. + +**Per explicit user instruction: do not merge this branch automatically.** Wait for the user's own manual click-through in a real browser before merging — unlike every prior phase, where merge was pre-approved. + ## Next step -This fix-up pass needs to be committed, pushed, and merged via PR (same workflow as every phase — own branch, no Claude/Anthropic references). After that: no more build work planned. The user's next intent is a Q&A/walkthrough session to fully understand the codebase ahead of the oral defense (explain architecture, justify decisions, describe traffic flow, name an error scenario and its handling). Possible further follow-ups if wanted later: a real notification send (email/push, still a `TODO` in `services/notification/src/index.ts`), CI badge/status in `README.md`, or load-testing the outbox poller under concurrent order volume. +Frontend branch is implemented, tested, and verified server-side — pending the user's own manual browser click-through, then commit/push/PR/merge. After that: no more build work planned. The user's next intent is a Q&A/walkthrough session to fully understand the codebase ahead of the oral defense (explain architecture, justify decisions, describe traffic flow, name an error scenario and its handling). Possible further follow-ups if wanted later: a real notification send (email/push, still a `TODO` in `services/notification/src/index.ts`), CI badge/status in `README.md`, or load-testing the outbox poller under concurrent order volume. ## Key files to know diff --git a/CLAUDE.md b/CLAUDE.md index efc4091..21bdc44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ - Microservices, each owning **only its own** PostgreSQL database. A service **never** reads or writes another service's tables. - Inter-service workflow communication is via **RabbitMQ events only**. No direct service-to-service HTTP calls for the order workflow. - Synchronous HTTP exists **only** on the path `client → Nginx → API gateway → service` for user-facing reads (menu, order status). -- The gateway is the single public entry point. Individual services are not exposed publicly. +- **Nginx is the single public entry point** — it routes `/api/*` and `/health` to the gateway, and everything else to the `frontend` container. Neither the gateway, the frontend, nor any individual service is exposed publicly. Flow: `Order` publishes `order.placed` → `Kitchen` consumes it, then publishes `order.accepted` and `order.ready` → `Notification` consumes all order events. @@ -17,7 +17,8 @@ Flow: `Order` publishes `order.placed` → `Kitchen` consumes it, then publishes - Messaging: **RabbitMQ** (topic exchange, durable queues, DLQ) - Data: **PostgreSQL**, one database per service - Cache + idempotency: **Redis** (menu cache with TTL, event dedup with SETNX + TTL) -- Edge: **Nginx** reverse proxy / load balancer in front of the gateway +- Edge: **Nginx** reverse proxy / load balancer in front of the gateway and the frontend +- Frontend: **React + Vite + TypeScript**, served as static files by its own Bun-based static server (`frontend/serve.ts`) — additive, non-graded, for manual click-testing - Local orchestration: **Docker Compose** - CI/CD: **GitHub Actions** - Tests: **bun test** + **Testcontainers** for integration @@ -37,6 +38,7 @@ quickbite/ │ ├── order/ │ ├── kitchen/ │ └── notification/ +├── frontend/ # React + Vite UI — its own container, not a "service" (no DB, no events) └── .claude/{commands,agents}/ ``` diff --git a/README.md b/README.md index 41a3b1f..2d5c5d1 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,23 @@ are the actual point, not just the CRUD surface. ## Architecture ``` -client → nginx (:80) → gateway → menu / order (synchronous reads) - | - v - order.placed (RabbitMQ) - | - +------------+------------+ - v v - kitchen notification - (accepts, then ready) (logs every order event) + +-- / --> frontend (React UI) +client → nginx (:80) --------+ + +-- /api/*, /health --> gateway → menu / order (synchronous reads) + | + v + order.placed (RabbitMQ) + | + +------------+------------+ + v v + kitchen notification + (accepts, then ready) (logs every order event) ``` - **nginx is the system's only public entry point.** Every other service — - RabbitMQ, Postgres, Redis, and even the gateway itself — is reachable only - from other containers on the internal Docker network, never from the host. + RabbitMQ, Postgres, Redis, the gateway, and the frontend container itself — + is reachable only from other containers on the internal Docker network, + never from the host. - Synchronous HTTP exists only on the path client → nginx → gateway → service, for user-facing reads (menu, order status). The actual order workflow (placed → accepted → ready → notified) happens entirely over RabbitMQ events. @@ -60,6 +63,23 @@ Watch `docker compose logs -f kitchen notification` to see the event flow: kitchen accepts the order immediately, marks it ready ~3s later (simulated cook time), and notification logs all 3 order events as they happen. +## Frontend + +A small React UI lives at `http://localhost/` (same nginx, same port 80) — +menu → cart → place order → a status screen that polls +`GET /api/orders/:id` live as it moves placed → accepted → ready, plus a +localStorage-backed order history. It's same-origin with the API (nginx +serves both under port 80), so there's no CORS configuration anywhere — +that's deliberate, not an oversight. + +For local frontend development with hot reload, run the backend stack as +usual and run the frontend separately on the host: + +```bash +docker compose up -d +bun run dev:frontend # Vite dev server; proxies /api to http://localhost:80 +``` + ### Local development For direct access to Postgres/RabbitMQ/Redis (e.g. a DB GUI, or running one @@ -79,7 +99,10 @@ bun run typecheck Runs automatically in CI on every push and pull request (`.github/workflows/ci.yml`). No mocks for any infrastructure — integration and e2e tests spin up real -Postgres/RabbitMQ/Redis containers. +Postgres/RabbitMQ/Redis containers. The frontend's tests are split the same +way: pure logic (`frontend/tests/lib/`, no DOM) and component-rendering +tests (`frontend/tests/components/`, real DOM via happy-dom + +Testing Library) — no backend changes needed for either. ## Resilience features diff --git a/bun.lock b/bun.lock index 8332fd7..1653562 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,24 @@ "typescript": "^5.6.0", }, }, + "frontend": { + "name": "@quickbite/frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.0", + "zod": "^3.23.8", + }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.10.6", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "vite": "^8.0.16", + }, + }, "packages/shared": { "name": "@quickbite/shared", "version": "0.0.0", @@ -102,8 +120,20 @@ }, }, "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], @@ -120,6 +150,8 @@ "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.10.6" } }, "sha512-Nu/IjRkkNxmeG2ywWsyJSO4d1BrWTqVzxCPL+gXj0b97klhmjd6wLzt6Bx/laNpkZ3WLW7zNCqmtMIbIlahqug=="], + "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], @@ -128,6 +160,10 @@ "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -152,6 +188,8 @@ "@quickbite/e2e": ["@quickbite/e2e@workspace:tests/e2e"], + "@quickbite/frontend": ["@quickbite/frontend@workspace:frontend"], + "@quickbite/gateway": ["@quickbite/gateway@workspace:services/gateway"], "@quickbite/kitchen": ["@quickbite/kitchen@workspace:services/kitchen"], @@ -164,12 +202,52 @@ "@quickbite/shared": ["@quickbite/shared@workspace:packages/shared"], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@testcontainers/postgresql": ["@testcontainers/postgresql@12.0.3", "", { "dependencies": { "testcontainers": "^12.0.3" } }, "sha512-y0l/ItWxNfdMU783EOZ66lMq90uuuXPqE+fo1jNSRfMbtfUq/9Ez/wluDxxaS+D/i3I0juep+jmz/WVuip8i9Q=="], "@testcontainers/rabbitmq": ["@testcontainers/rabbitmq@12.0.3", "", { "dependencies": { "testcontainers": "^12.0.3" } }, "sha512-GXPVAQToa8u/fjDstvd1etVvoWHbjeXOdWchWR+OFK7JFQ98Qki73PbdwyaclssQVL1ZZhEiNtdpNx5daOwOlA=="], "@testcontainers/redis": ["@testcontainers/redis@12.0.3", "", { "dependencies": { "testcontainers": "^12.0.3" } }, "sha512-hN3YedOSb6quFzG1IM4j/r/M635GcaYOxzbTuiSN7676BZHrx1VPl+xll3ojk8ds6TvenrfO37lrEVJYN2URlA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="], @@ -178,10 +256,20 @@ "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/ssh2": ["@types/ssh2@0.5.52", "", { "dependencies": { "@types/node": "*", "@types/ssh2-streams": "*" } }, "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg=="], "@types/ssh2-streams": ["@types/ssh2-streams@0.1.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], @@ -194,12 +282,14 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="], "archiver-utils": ["archiver-utils@5.0.2", "", { "dependencies": { "glob": "^10.0.0", "graceful-fs": "^4.2.0", "is-stream": "^2.0.1", "lazystream": "^1.0.0", "lodash": "^4.17.15", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -238,6 +328,8 @@ "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -268,24 +360,32 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "docker-compose": ["docker-compose@1.4.2", "", { "dependencies": { "yaml": "^2.2.2" } }, "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww=="], "docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="], "dockerode": ["dockerode@5.0.0", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4" } }, "sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -310,12 +410,16 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "find-my-way": ["find-my-way@9.6.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="], @@ -324,6 +428,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -342,6 +448,8 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -350,6 +458,30 @@ "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], @@ -358,6 +490,8 @@ "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -370,6 +504,8 @@ "nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -382,14 +518,22 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], @@ -406,6 +550,16 @@ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-router": ["react-router@7.18.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ=="], + + "react-router-dom": ["react-router-dom@7.18.0", "", { "dependencies": { "react-router": "7.18.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], @@ -428,6 +582,8 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="], @@ -436,6 +592,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], @@ -450,6 +608,8 @@ "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -484,10 +644,14 @@ "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "toad-cache": ["toad-cache@3.7.1", "", {}, "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -498,6 +662,10 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -506,6 +674,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -544,6 +714,10 @@ "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/docker-compose.yml b/docker-compose.yml index 2f38df4..556a747 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ services: # No published ports below except nginx — nginx is the system's only - # public entry point. RabbitMQ/Postgres/Redis/the gateway are reachable - # only from other containers on the internal Docker network, by service - # name. For local debugging access to these, use docker-compose.dev.yml. + # public entry point. RabbitMQ/Postgres/Redis/the gateway/the frontend are + # reachable only from other containers on the internal Docker network, by + # service name. For local debugging access to these, use docker-compose.dev.yml. rabbitmq: image: rabbitmq:3-management healthcheck: @@ -87,13 +87,27 @@ services: postgres: { condition: service_healthy } redis: { condition: service_healthy } + frontend: + build: { context: ., dockerfile: frontend/Dockerfile } + environment: + PORT: 3005 + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://localhost:3005/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 10s + timeout: 5s + retries: 5 + # The system's single public entry point — every other service above is # unreachable from outside the Docker network. nginx: image: nginx:alpine volumes: ["./nginx/nginx.conf:/etc/nginx/nginx.conf:ro"] ports: ["80:80"] - depends_on: [gateway] + depends_on: + gateway: + condition: service_started + frontend: + condition: service_healthy volumes: pgdata: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6335500 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,8 @@ +FROM oven/bun:1 +WORKDIR /app +COPY . . +RUN bun install +RUN bun run --filter @quickbite/frontend build +HEALTHCHECK --interval=10s --timeout=5s --retries=5 \ + CMD bun -e "fetch('http://localhost:3005/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" +CMD ["bun", "run", "frontend/serve.ts"] diff --git a/frontend/bunfig.toml b/frontend/bunfig.toml new file mode 100644 index 0000000..12c47df --- /dev/null +++ b/frontend/bunfig.toml @@ -0,0 +1,3 @@ +[test] +preload = "./tests/setup.ts" +timeout = 60000 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..44a69ac --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + QuickBite + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8d65a6d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "@quickbite/frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "bun test --timeout 60000" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.10.6", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "vite": "^8.0.16" + } +} diff --git a/frontend/serve.ts b/frontend/serve.ts new file mode 100644 index 0000000..2ab7dbd --- /dev/null +++ b/frontend/serve.ts @@ -0,0 +1,32 @@ +import { resolve, sep } from "node:path"; + +const PORT = Number(process.env.PORT ?? 3005); +const DIST_DIR = resolve(import.meta.dir, "dist"); + +Bun.serve({ + port: PORT, + hostname: "0.0.0.0", + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/health") return Response.json({ status: "ok", service: "frontend" }); + + const decodedPath = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + const resolved = resolve(DIST_DIR, decodedPath); + + // Boundary check on the RESOLVED absolute path, not a raw string + // startsWith — "/app/dist-evil".startsWith("/app/dist") is true and is + // the classic traversal bypass. Must also check the path separator so a + // sibling directory that happens to share the prefix can't match. + const withinDist = resolved === DIST_DIR || resolved.startsWith(DIST_DIR + sep); + const file = withinDist ? Bun.file(resolved) : null; + + if (file && (await file.exists())) return new Response(file); + + // SPA fallback for any unmatched path (a client-side react-router + // route, e.g. /orders/) — must exist, or a hard refresh on a deep + // link 404s instead of loading the app. + return new Response(Bun.file(resolve(DIST_DIR, "index.html"))); + }, +}); + +console.log(`🚀 frontend on ${PORT}`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c1bfce7 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,20 @@ +import { BrowserRouter, Link, Route, Routes } from "react-router-dom"; +import { MenuPage } from "./pages/MenuPage.tsx"; +import { OrderHistoryPage } from "./pages/OrderHistoryPage.tsx"; +import { OrderStatusPage } from "./pages/OrderStatusPage.tsx"; + +export function App() { + return ( + + + + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..ae60cf1 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,62 @@ +import { OrderItems } from "./lib/orderItem.ts"; +import type { CartLine, MenuItem, OrderDetail, OrderSummary } from "./types.ts"; + +export class ApiError extends Error { + constructor( + message: string, + public readonly status?: number, + ) { + super(message); + } +} + +export class OrderNotFoundError extends ApiError { + constructor(orderId: string) { + super(`order ${orderId} not found`, 404); + } +} + +// Step 1: Read the menu. Throws ApiError on any non-2xx response — callers +// decide how to present that (error banner + retry), this layer never +// swallows a failure silently. +export async function getMenu(): Promise { + const res = await fetch("/api/menu"); + if (!res.ok) throw new ApiError("failed to load menu", res.status); + return res.json(); +} + +// Step 2: Place an order. Validates client-side first (friendly, fast +// feedback) using the same rules the backend enforces — see +// lib/orderItem.ts for why this is a duplicated schema, not a shared import. +// The backend's own zod validation in services/order/src/server.ts remains +// the actual source of truth; this never replaces it. +export async function placeOrder(customerId: string, items: CartLine[]): Promise { + const payload = items.map((item) => ({ + menuItemId: item.menuItemId, + name: item.name, + quantity: item.quantity, + priceCents: item.priceCents, + })); + + const parsed = OrderItems.safeParse(payload); + if (!parsed.success) throw new ApiError("cart contains invalid items"); + + const res = await fetch("/api/orders", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ customerId, items: parsed.data }), + }); + + if (!res.ok) throw new ApiError("failed to place order", res.status); + return res.json(); +} + +// Step 3: Read an order's current status. A 404 maps to OrderNotFoundError +// specifically, so useOrderPolling can tell "not found yet" (keep polling +// within the bounded window) apart from any other failure. +export async function getOrder(orderId: string, signal?: AbortSignal): Promise { + const res = await fetch(`/api/orders/${orderId}`, { signal }); + if (res.status === 404) throw new OrderNotFoundError(orderId); + if (!res.ok) throw new ApiError("failed to load order", res.status); + return res.json(); +} diff --git a/frontend/src/components/Cart.tsx b/frontend/src/components/Cart.tsx new file mode 100644 index 0000000..bc5d790 --- /dev/null +++ b/frontend/src/components/Cart.tsx @@ -0,0 +1,37 @@ +import { computeCartTotal } from "../lib/cartTotal.ts"; +import { formatCents } from "../lib/money.ts"; +import type { CartLine } from "../types.ts"; + +type Props = { + lines: CartLine[]; + placing: boolean; + error?: string; + onPlaceOrder: () => void; +}; + +export function Cart({ lines, placing, error, onPlaceOrder }: Props) { + const total = computeCartTotal(lines); + const isEmpty = lines.length === 0; + + return ( +
+

Your order

+ {isEmpty ? ( +

Your cart is empty.

+ ) : ( +
    + {lines.map((line) => ( +
  • + {line.quantity} × {line.name} — ${formatCents(line.priceCents * line.quantity)} +
  • + ))} +
+ )} +

Total: ${formatCents(total)}

+ {error ?

{error}

: null} + +
+ ); +} diff --git a/frontend/src/components/MenuList.tsx b/frontend/src/components/MenuList.tsx new file mode 100644 index 0000000..d8d4049 --- /dev/null +++ b/frontend/src/components/MenuList.tsx @@ -0,0 +1,42 @@ +import { formatCents } from "../lib/money.ts"; +import type { MenuItem } from "../types.ts"; + +type Props = { + items: MenuItem[]; + quantities: Record; + onChangeQuantity: (menuItemId: string, quantity: number) => void; +}; + +export function MenuList({ items, quantities, onChangeQuantity }: Props) { + return ( +
    + {items.map((item) => { + const quantity = quantities[item.id] ?? 0; + return ( +
  • + {item.name} + ${formatCents(item.price_cents)} +
    + + {quantity} + +
    +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/OrderStatus.tsx b/frontend/src/components/OrderStatus.tsx new file mode 100644 index 0000000..2b99c4f --- /dev/null +++ b/frontend/src/components/OrderStatus.tsx @@ -0,0 +1,50 @@ +import { formatCents } from "../lib/money.ts"; +import type { PollState } from "../hooks/useOrderPolling.ts"; + +const STAGES = ["placed", "accepted", "ready"] as const; + +export function OrderStatus({ state }: { state: PollState }) { + if (state.phase === "loading") return

Loading order…

; + if (state.phase === "error") return

Something went wrong: {state.message}

; + + if (state.phase === "timed-out") { + return ( +
+

+ Still processing — this is taking longer than expected. Check back in a bit. +

+ {state.order ? : null} +
+ ); + } + + const { order } = state; + return ( +
+ +

Order #{order.orderId}

+

Total: ${formatCents(order.totalCents)}

+
    + {order.items.map((item) => ( +
  • + {item.quantity} × {item.name} +
  • + ))} +
+
+ ); +} + +function OrderStages({ status }: { status: string }) { + const currentIndex = STAGES.indexOf(status as (typeof STAGES)[number]); + + return ( +
    + {STAGES.map((stage, index) => ( +
  1. + {stage} +
  2. + ))} +
+ ); +} diff --git a/frontend/src/hooks/useOrderPolling.ts b/frontend/src/hooks/useOrderPolling.ts new file mode 100644 index 0000000..343c486 --- /dev/null +++ b/frontend/src/hooks/useOrderPolling.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import { getOrder, OrderNotFoundError } from "../api.ts"; +import type { OrderDetail } from "../types.ts"; + +const POLL_INTERVAL_MS = 1500; +const TIMEOUT_MS = 60_000; + +export type PollState = + | { phase: "loading" } + | { phase: "polling"; order: OrderDetail } + | { phase: "ready"; order: OrderDetail } + | { phase: "timed-out"; order: OrderDetail | null } + | { phase: "error"; message: string }; + +// Polls GET /api/orders/:id until status reaches "ready" or TIMEOUT_MS +// elapses. Every safeguard below addresses a specific, real bug class — +// not defensive-for-its-own-sake: +// +// - setTimeout-chain, not setInterval: the next poll is only scheduled +// after the current one resolves, so a slow response (e.g. a cold +// container) can never cause overlapping in-flight requests. +// - everything the closure needs lives inside this effect, keyed on +// [orderId] — re-mounting with a different id (e.g. from order history) +// can never keep polling the old one. +// - an AbortController is aborted on cleanup, and a `cancelled` flag is +// checked before every setState — a fetch that resolves after unmount +// never touches state on a dead component. +// - a 404 (OrderNotFoundError) is treated as "not found YET", not fatal, +// within the bounded window — the outbox pattern means the order row can +// briefly lag right after POST /api/orders returns 201. +// - hitting the timeout without reaching "ready" surfaces an explicit +// "timed-out" phase — never a silently-spinning UI forever. +export function useOrderPolling(orderId: string): PollState { + const [state, setState] = useState({ phase: "loading" }); + + useEffect(() => { + if (!orderId) return; // defensive only — react-router guarantees this route param is present + + let cancelled = false; + let timer: ReturnType | undefined; + const controller = new AbortController(); + const deadline = Date.now() + TIMEOUT_MS; + let lastOrder: OrderDetail | null = null; + + async function poll(): Promise { + try { + const order = await getOrder(orderId, controller.signal); + if (cancelled) return; + lastOrder = order; + + if (order.status === "ready") { + setState({ phase: "ready", order }); + return; + } + + setState({ phase: "polling", order }); + } catch (err) { + if (cancelled) return; + + if (!(err instanceof OrderNotFoundError)) { + setState({ phase: "error", message: err instanceof Error ? err.message : "unknown error" }); + return; + } + } + + if (cancelled) return; + + if (Date.now() >= deadline) { + setState({ phase: "timed-out", order: lastOrder }); + return; + } + + timer = setTimeout(poll, POLL_INTERVAL_MS); + } + + poll(); + + return () => { + cancelled = true; + controller.abort(); + if (timer) clearTimeout(timer); + }; + }, [orderId]); + + return state; +} diff --git a/frontend/src/lib/cartTotal.ts b/frontend/src/lib/cartTotal.ts new file mode 100644 index 0000000..1811aa2 --- /dev/null +++ b/frontend/src/lib/cartTotal.ts @@ -0,0 +1,9 @@ +import type { CartLine } from "../types.ts"; + +// A client-side PREVIEW total only — the server always recomputes the +// authoritative total itself (services/order/src/pricing.ts#computeTotalCents). +// Deliberately not shared/imported across that boundary: the client must +// never be the source of truth for what a customer is charged. +export function computeCartTotal(lines: CartLine[]): number { + return lines.reduce((sum, line) => sum + line.priceCents * line.quantity, 0); +} diff --git a/frontend/src/lib/customerId.ts b/frontend/src/lib/customerId.ts new file mode 100644 index 0000000..78a7a8a --- /dev/null +++ b/frontend/src/lib/customerId.ts @@ -0,0 +1,15 @@ +const STORAGE_KEY = "quickbite.customerId"; + +// This app has no auth — a customerId is generated once per browser and +// persisted, then reused for every order placed from that browser. +// `storage` is an injectable parameter (defaults to the real localStorage) +// specifically so this stays a true "no I/O" pure unit test — a fake +// in-memory Storage can be passed in tests instead of needing a real DOM. +export function getOrCreateCustomerId(storage: Storage = localStorage): string { + const existing = storage.getItem(STORAGE_KEY); + if (existing) return existing; + + const id = crypto.randomUUID(); + storage.setItem(STORAGE_KEY, id); + return id; +} diff --git a/frontend/src/lib/money.ts b/frontend/src/lib/money.ts new file mode 100644 index 0000000..e1a8456 --- /dev/null +++ b/frontend/src/lib/money.ts @@ -0,0 +1,5 @@ +// Formats integer cents as a 2-decimal currency string, e.g. 1200 -> "12.00". +// Pure — no I/O — so it's directly unit-testable with bun test. +export function formatCents(cents: number): string { + return (cents / 100).toFixed(2); +} diff --git a/frontend/src/lib/orderHistory.ts b/frontend/src/lib/orderHistory.ts new file mode 100644 index 0000000..7deaaac --- /dev/null +++ b/frontend/src/lib/orderHistory.ts @@ -0,0 +1,30 @@ +const STORAGE_KEY = "quickbite.orderHistory"; +const MAX_ENTRIES = 10; + +export type HistoryEntry = { orderId: string; placedAt: string }; + +// `storage` is injectable (defaults to the real localStorage) for the same +// "no I/O" pure-unit-test reason as lib/customerId.ts. +export function getOrderHistory(storage: Storage = localStorage): HistoryEntry[] { + const raw = storage.getItem(STORAGE_KEY); + if (!raw) return []; + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + // Corrupt/foreign localStorage content must never crash the app — treat + // it the same as "no history yet". + return []; + } +} + +// Most-recent-first, capped at MAX_ENTRIES, de-duplicated by orderId (a +// revisit moves an existing entry back to the front instead of duplicating +// it). +export function addOrderToHistory(entry: HistoryEntry, storage: Storage = localStorage): HistoryEntry[] { + const existing = getOrderHistory(storage).filter((e) => e.orderId !== entry.orderId); + const updated = [entry, ...existing].slice(0, MAX_ENTRIES); + storage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; +} diff --git a/frontend/src/lib/orderItem.ts b/frontend/src/lib/orderItem.ts new file mode 100644 index 0000000..b64752b --- /dev/null +++ b/frontend/src/lib/orderItem.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +// Intentionally duplicates packages/shared/src/events.ts#OrderItem rather +// than importing it — that package's barrel re-exports mq.ts/idempotency.ts, +// which pull in amqplib/ioredis (Node-only, not browser-safe). This is a +// client-side PREVIEW check only, same "client previews, server stays +// authoritative" reasoning as cartTotal.ts: the backend's own zod +// validation in services/order/src/server.ts is the real source of truth, +// this just gives the user a friendly error before the request even leaves +// the browser. +export const OrderItem = z.object({ + menuItemId: z.string().uuid(), + name: z.string().min(1), + quantity: z.number().int().positive(), + priceCents: z.number().int().nonnegative(), +}); + +export const OrderItems = z.array(OrderItem).min(1); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..0e4abaf --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("missing #root element in index.html"); + +createRoot(root).render( + + + , +); diff --git a/frontend/src/pages/MenuPage.tsx b/frontend/src/pages/MenuPage.tsx new file mode 100644 index 0000000..321fbf8 --- /dev/null +++ b/frontend/src/pages/MenuPage.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ApiError, getMenu, placeOrder } from "../api.ts"; +import { Cart } from "../components/Cart.tsx"; +import { MenuList } from "../components/MenuList.tsx"; +import { getOrCreateCustomerId } from "../lib/customerId.ts"; +import { addOrderToHistory } from "../lib/orderHistory.ts"; +import type { CartLine, MenuItem } from "../types.ts"; + +export function MenuPage() { + const navigate = useNavigate(); + const [items, setItems] = useState(null); + const [loadError, setLoadError] = useState(null); + const [quantities, setQuantities] = useState>({}); + const [placing, setPlacing] = useState(false); + const [placeError, setPlaceError] = useState(undefined); + + useEffect(() => { + let cancelled = false; + getMenu() + .then((menu) => { + if (!cancelled) setItems(menu); + }) + .catch(() => { + if (!cancelled) setLoadError("Could not load the menu. Is the backend running?"); + }); + return () => { + cancelled = true; + }; + }, []); + + function handleChangeQuantity(menuItemId: string, quantity: number) { + setQuantities((prev) => { + const next = { ...prev, [menuItemId]: quantity }; + if (quantity === 0) delete next[menuItemId]; + return next; + }); + } + + const lines: CartLine[] = (items ?? []) + .filter((item) => (quantities[item.id] ?? 0) > 0) + .map((item) => ({ + menuItemId: item.id, + name: item.name, + priceCents: item.price_cents, + quantity: quantities[item.id] ?? 0, + })); + + async function handlePlaceOrder() { + setPlaceError(undefined); + setPlacing(true); + try { + const customerId = getOrCreateCustomerId(); + const summary = await placeOrder(customerId, lines); + addOrderToHistory({ orderId: summary.orderId, placedAt: new Date().toISOString() }); + setQuantities({}); + navigate(`/orders/${summary.orderId}`); + } catch (err) { + setPlaceError(err instanceof ApiError ? err.message : "Failed to place order."); + } finally { + setPlacing(false); + } + } + + if (loadError) return

{loadError}

; + if (!items) return

Loading menu…

; + + return ( +
+

QuickBite

+ + +
+ ); +} diff --git a/frontend/src/pages/OrderHistoryPage.tsx b/frontend/src/pages/OrderHistoryPage.tsx new file mode 100644 index 0000000..3a60aaf --- /dev/null +++ b/frontend/src/pages/OrderHistoryPage.tsx @@ -0,0 +1,19 @@ +import { Link } from "react-router-dom"; +import { getOrderHistory } from "../lib/orderHistory.ts"; + +export function OrderHistoryPage() { + const history = getOrderHistory(); + + if (history.length === 0) return

No past orders yet on this browser.

; + + return ( +
    + {history.map((entry) => ( +
  • + {entry.orderId} + — placed {new Date(entry.placedAt).toLocaleString()} +
  • + ))} +
+ ); +} diff --git a/frontend/src/pages/OrderStatusPage.tsx b/frontend/src/pages/OrderStatusPage.tsx new file mode 100644 index 0000000..3da5cf0 --- /dev/null +++ b/frontend/src/pages/OrderStatusPage.tsx @@ -0,0 +1,21 @@ +import { Link, useParams } from "react-router-dom"; +import { OrderStatus } from "../components/OrderStatus.tsx"; +import { useOrderPolling } from "../hooks/useOrderPolling.ts"; + +export function OrderStatusPage() { + const { orderId } = useParams<{ orderId: string }>(); + // The hook is always called, unconditionally, regardless of whether + // orderId is present — calling hooks conditionally breaks React's rules + // of hooks. useOrderPolling itself no-ops on an empty id (see its own + // defensive guard). + const state = useOrderPolling(orderId ?? ""); + + if (!orderId) return

Missing order id.

; + + return ( +
+ + Back to menu +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..8f6402a --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,74 @@ +body { + font-family: system-ui, sans-serif; + max-width: 640px; + margin: 2rem auto; + padding: 0 1rem; +} + +.app-nav { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.menu-list { + list-style: none; + padding: 0; +} + +.menu-list__item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px solid #eee; +} + +.menu-list__name { + flex: 1; +} + +.menu-list__stepper { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cart { + margin-top: 2rem; + padding-top: 1rem; + border-top: 2px solid #333; +} + +.cart__error, +.menu-page__error, +.order-status__error { + color: #b00020; +} + +.order-status__timeout { + color: #b06000; +} + +.order-stages { + display: flex; + gap: 1rem; + list-style: none; + padding: 0; +} + +.order-stages li { + padding: 0.25rem 0.75rem; + border: 1px solid #ccc; + border-radius: 1rem; + text-transform: capitalize; +} + +.order-stages li[data-done="true"] { + background: #e6f4ea; + border-color: #34a853; +} + +.order-stages li[aria-current="true"] { + font-weight: bold; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..da10416 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,36 @@ +// Mirrors the gateway's actual response/request shapes (verified directly +// against services/menu/src/index.ts, services/order/src/server.ts, and +// services/order/src/db.ts — not guessed). Kept local to the frontend +// rather than imported from @quickbite/shared: that package's barrel +// (packages/shared/src/index.ts) re-exports mq.ts/idempotency.ts, which +// pull in amqplib/ioredis — Node-only libraries that aren't browser-safe +// and would otherwise get dragged into the frontend's bundle. + +export type MenuItem = { + id: string; + name: string; + price_cents: number; +}; + +export type CartLine = { + menuItemId: string; + name: string; + priceCents: number; + quantity: number; +}; + +export type OrderStatus = "placed" | "accepted" | "ready"; + +export type OrderSummary = { + orderId: string; + status: OrderStatus | string; + totalCents: number; +}; + +export type OrderDetail = { + orderId: string; + customerId: string; + status: OrderStatus | string; + totalCents: number; + items: CartLine[]; +}; diff --git a/frontend/tests/components/Cart.test.tsx b/frontend/tests/components/Cart.test.tsx new file mode 100644 index 0000000..ff23003 --- /dev/null +++ b/frontend/tests/components/Cart.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { Cart } from "../../src/components/Cart.tsx"; + +const LINES = [ + { menuItemId: "1", name: "Margherita", priceCents: 1200, quantity: 2 }, + { menuItemId: "2", name: "Pepperoni", priceCents: 1400, quantity: 1 }, +]; + +describe("Cart", () => { + test("recalculates the total from quantities", () => { + render( {}} />); + expect(screen.getByText("Total: $38.00")).toBeDefined(); + }); + + test("the place order button is disabled when the cart is empty", () => { + render( {}} />); + expect((screen.getByText("Place order") as HTMLButtonElement).disabled).toBe(true); + }); + + test("the place order button is disabled while placing", () => { + render( {}} />); + expect((screen.getByText("Placing order…") as HTMLButtonElement).disabled).toBe(true); + }); + + test("clicking place order calls the handler", () => { + let called = false; + render( (called = true)} />); + fireEvent.click(screen.getByText("Place order")); + expect(called).toBe(true); + }); + + test("renders an error message when given one", () => { + render( {}} />); + expect(screen.getByText("cart contains invalid items")).toBeDefined(); + }); +}); diff --git a/frontend/tests/components/MenuList.test.tsx b/frontend/tests/components/MenuList.test.tsx new file mode 100644 index 0000000..f7e5bfa --- /dev/null +++ b/frontend/tests/components/MenuList.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MenuList } from "../../src/components/MenuList.tsx"; + +const ITEMS = [ + { id: "1", name: "Margherita", price_cents: 1200 }, + { id: "2", name: "Pepperoni", price_cents: 1400 }, +]; + +describe("MenuList", () => { + test("renders every item with its formatted price", () => { + render( {}} />); + expect(screen.getByText("Margherita")).toBeDefined(); + expect(screen.getByText("$12.00")).toBeDefined(); + expect(screen.getByText("Pepperoni")).toBeDefined(); + expect(screen.getByText("$14.00")).toBeDefined(); + }); + + test("the decrease button is disabled at zero quantity", () => { + render( {}} />); + const decreaseButtons = screen.getAllByLabelText(/Decrease/); + expect((decreaseButtons[0] as HTMLButtonElement).disabled).toBe(true); + }); + + test("clicking increase calls onChangeQuantity with quantity + 1", () => { + let lastCall: [string, number] | null = null; + render( + (lastCall = [id, qty])} />, + ); + fireEvent.click(screen.getByLabelText("Increase Margherita quantity")); + expect(lastCall).toEqual(["1", 3]); + }); +}); diff --git a/frontend/tests/components/OrderStatus.test.tsx b/frontend/tests/components/OrderStatus.test.tsx new file mode 100644 index 0000000..66b4040 --- /dev/null +++ b/frontend/tests/components/OrderStatus.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { render, screen } from "@testing-library/react"; +import { OrderStatus } from "../../src/components/OrderStatus.tsx"; +import type { PollState } from "../../src/hooks/useOrderPolling.ts"; + +const ORDER = { + orderId: "abc-123", + customerId: "cust-1", + totalCents: 1200, + items: [{ menuItemId: "1", name: "Margherita", priceCents: 1200, quantity: 1 }], +}; + +describe("OrderStatus", () => { + test("shows a loading message while loading", () => { + render(); + expect(screen.getByText("Loading order…")).toBeDefined(); + }); + + test("marks the current stage when polling", () => { + const state: PollState = { phase: "polling", order: { ...ORDER, status: "accepted" } }; + render(); + const accepted = screen.getByText("accepted"); + expect(accepted.getAttribute("aria-current")).toBe("true"); + }); + + test("shows the ready stage and order details once ready", () => { + const state: PollState = { phase: "ready", order: { ...ORDER, status: "ready" } }; + render(); + expect(screen.getByText("Order #abc-123")).toBeDefined(); + expect(screen.getByText("ready").getAttribute("aria-current")).toBe("true"); + }); + + test("shows an explicit message on timeout, never a silent spinner", () => { + const state: PollState = { phase: "timed-out", order: { ...ORDER, status: "accepted" } }; + render(); + expect(screen.getByText(/Still processing/)).toBeDefined(); + }); + + test("shows the error message on failure", () => { + render(); + expect(screen.getByText(/network down/)).toBeDefined(); + }); +}); diff --git a/frontend/tests/lib/cartTotal.test.ts b/frontend/tests/lib/cartTotal.test.ts new file mode 100644 index 0000000..040e7c3 --- /dev/null +++ b/frontend/tests/lib/cartTotal.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { computeCartTotal } from "../../src/lib/cartTotal.ts"; + +describe("computeCartTotal", () => { + test("sums price * quantity across multiple lines", () => { + const total = computeCartTotal([ + { menuItemId: "1", name: "Margherita", priceCents: 1200, quantity: 2 }, + { menuItemId: "2", name: "Pepperoni", priceCents: 1400, quantity: 1 }, + ]); + expect(total).toBe(3800); + }); + + test("handles a single line", () => { + expect(computeCartTotal([{ menuItemId: "1", name: "Margherita", priceCents: 999, quantity: 1 }])).toBe(999); + }); + + test("returns 0 for an empty cart", () => { + expect(computeCartTotal([])).toBe(0); + }); +}); diff --git a/frontend/tests/lib/customerId.test.ts b/frontend/tests/lib/customerId.test.ts new file mode 100644 index 0000000..9875b02 --- /dev/null +++ b/frontend/tests/lib/customerId.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { getOrCreateCustomerId } from "../../src/lib/customerId.ts"; + +// A minimal in-memory Storage fake — keeps this a true "no I/O" unit test, +// no real DOM/localStorage needed even though happy-dom is registered for +// component tests elsewhere in this package. +function fakeStorage(): Storage { + const map = new Map(); + return { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value), + removeItem: (key) => void map.delete(key), + clear: () => map.clear(), + key: (index) => Array.from(map.keys())[index] ?? null, + get length() { + return map.size; + }, + }; +} + +describe("getOrCreateCustomerId", () => { + test("generates a UUID on first call", () => { + const id = getOrCreateCustomerId(fakeStorage()); + expect(id).toMatch(/^[0-9a-f-]{36}$/); + }); + + test("returns the same id on subsequent calls with the same storage", () => { + const storage = fakeStorage(); + const first = getOrCreateCustomerId(storage); + const second = getOrCreateCustomerId(storage); + expect(second).toBe(first); + }); + + test("generates a different id for a fresh storage", () => { + const a = getOrCreateCustomerId(fakeStorage()); + const b = getOrCreateCustomerId(fakeStorage()); + expect(a).not.toBe(b); + }); +}); diff --git a/frontend/tests/lib/money.test.ts b/frontend/tests/lib/money.test.ts new file mode 100644 index 0000000..8cdb03b --- /dev/null +++ b/frontend/tests/lib/money.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { formatCents } from "../../src/lib/money.ts"; + +describe("formatCents", () => { + test("formats whole dollar amounts", () => { + expect(formatCents(1200)).toBe("12.00"); + }); + + test("formats amounts with cents", () => { + expect(formatCents(1299)).toBe("12.99"); + }); + + test("formats zero", () => { + expect(formatCents(0)).toBe("0.00"); + }); +}); diff --git a/frontend/tests/lib/orderHistory.test.ts b/frontend/tests/lib/orderHistory.test.ts new file mode 100644 index 0000000..7627c56 --- /dev/null +++ b/frontend/tests/lib/orderHistory.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { addOrderToHistory, getOrderHistory } from "../../src/lib/orderHistory.ts"; + +function fakeStorage(): Storage { + const map = new Map(); + return { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value), + removeItem: (key) => void map.delete(key), + clear: () => map.clear(), + key: (index) => Array.from(map.keys())[index] ?? null, + get length() { + return map.size; + }, + }; +} + +describe("getOrderHistory", () => { + test("returns an empty array when nothing is stored", () => { + expect(getOrderHistory(fakeStorage())).toEqual([]); + }); + + test("returns an empty array for corrupt storage content, never throws", () => { + const storage = fakeStorage(); + storage.setItem("quickbite.orderHistory", "{not json"); + expect(getOrderHistory(storage)).toEqual([]); + }); +}); + +describe("addOrderToHistory", () => { + test("adds the most recent order to the front", () => { + const storage = fakeStorage(); + addOrderToHistory({ orderId: "a", placedAt: "2026-01-01T00:00:00Z" }, storage); + const updated = addOrderToHistory({ orderId: "b", placedAt: "2026-01-02T00:00:00Z" }, storage); + expect(updated.map((e) => e.orderId)).toEqual(["b", "a"]); + }); + + test("de-duplicates by orderId, moving the existing entry to the front", () => { + const storage = fakeStorage(); + addOrderToHistory({ orderId: "a", placedAt: "2026-01-01T00:00:00Z" }, storage); + addOrderToHistory({ orderId: "b", placedAt: "2026-01-02T00:00:00Z" }, storage); + const updated = addOrderToHistory({ orderId: "a", placedAt: "2026-01-03T00:00:00Z" }, storage); + expect(updated).toEqual([ + { orderId: "a", placedAt: "2026-01-03T00:00:00Z" }, + { orderId: "b", placedAt: "2026-01-02T00:00:00Z" }, + ]); + }); + + test("caps history at 10 entries", () => { + const storage = fakeStorage(); + for (let i = 0; i < 12; i++) { + addOrderToHistory({ orderId: `order-${i}`, placedAt: new Date().toISOString() }, storage); + } + expect(getOrderHistory(storage)).toHaveLength(10); + }); +}); diff --git a/frontend/tests/lib/orderItem.test.ts b/frontend/tests/lib/orderItem.test.ts new file mode 100644 index 0000000..2d971c1 --- /dev/null +++ b/frontend/tests/lib/orderItem.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { OrderItem, OrderItems } from "../../src/lib/orderItem.ts"; + +const VALID = { + menuItemId: "44444444-4444-4444-4444-444444444444", + name: "Margherita", + quantity: 1, + priceCents: 1200, +}; + +describe("OrderItem", () => { + test("accepts a valid item", () => { + expect(OrderItem.safeParse(VALID).success).toBe(true); + }); + + test("rejects a non-uuid menuItemId", () => { + expect(OrderItem.safeParse({ ...VALID, menuItemId: "not-a-uuid" }).success).toBe(false); + }); + + test("rejects a zero quantity", () => { + expect(OrderItem.safeParse({ ...VALID, quantity: 0 }).success).toBe(false); + }); + + test("rejects a negative priceCents", () => { + expect(OrderItem.safeParse({ ...VALID, priceCents: -1 }).success).toBe(false); + }); +}); + +describe("OrderItems", () => { + test("rejects an empty array", () => { + expect(OrderItems.safeParse([]).success).toBe(false); + }); + + test("accepts a non-empty array of valid items", () => { + expect(OrderItems.safeParse([VALID]).success).toBe(true); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..ca57f32 --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,23 @@ +import { afterEach } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// Registers a real DOM globally so component tests can use +// @testing-library/react under plain `bun test` — no jsdom needed. +GlobalRegistrator.register(); + +// @testing-library/react's `screen` binds to `document.body` once, at +// MODULE-LOAD time — and ESM hoists all `import` statements above any +// other code, regardless of where they're written. A static +// `import { cleanup } from "@testing-library/react"` at the top of this +// file would therefore load testing-library BEFORE the GlobalRegistrator +// call above ever runs, permanently breaking every `screen.*` query for the +// rest of the process. A dynamic import, after registration, avoids that. +const { cleanup } = await import("@testing-library/react"); + +// Without this, every test's rendered output stays in the shared happy-dom +// document (`bun test` runs all files in one process), so a later test's +// getByText() can match leftover elements from an earlier test/file and +// throw "multiple elements found" — unmount + clear the DOM after each test. +afterEach(() => { + cleanup(); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1c60964 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9e75dd2 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Dev-only: proxies /api straight to nginx (already published on :80), so +// the gateway never needs its own published port even for frontend +// development — same invariant as production, just exercised locally. +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:80", + }, + }, +}); diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 2dc9efc..4d61c4f 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -6,12 +6,36 @@ http { server gateway:3000; } + upstream frontend_upstream { + server frontend:3005; + } + server { listen 80; - location / { + + location = /health { + proxy_pass http://gateway_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # NO trailing slash on proxy_pass here — the gateway's own proxy code + # (services/gateway/src/index.ts) does req.url.slice("/api/menu".length) + # and expects to receive the full /api/... path unchanged. Adding a + # trailing slash to "http://gateway_upstream/" would strip this prefix + # before forwarding and break that. + location /api/ { proxy_pass http://gateway_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } + + # Everything else (the React app and its client-side routes) goes to + # the frontend container. + location / { + proxy_pass http://frontend_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } } } diff --git a/package.json b/package.json index 313242a..285db55 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "quickbite", "private": true, - "workspaces": ["packages/*", "services/*", "tests/*"], + "workspaces": ["packages/*", "services/*", "tests/*", "frontend"], "scripts": { - "typecheck": "tsc --noEmit -p packages/shared && tsc --noEmit -p services/gateway && tsc --noEmit -p services/menu && tsc --noEmit -p services/order && tsc --noEmit -p services/kitchen && tsc --noEmit -p services/notification && tsc --noEmit -p tests/e2e", + "typecheck": "tsc --noEmit -p packages/shared && tsc --noEmit -p services/gateway && tsc --noEmit -p services/menu && tsc --noEmit -p services/order && tsc --noEmit -p services/kitchen && tsc --noEmit -p services/notification && tsc --noEmit -p tests/e2e && tsc --noEmit -p frontend", "test": "bun run --filter '*' --sequential test", "dev:gateway": "bun run --filter @quickbite/gateway dev", "dev:menu": "bun run --filter @quickbite/menu dev", "dev:order": "bun run --filter @quickbite/order dev", "dev:kitchen": "bun run --filter @quickbite/kitchen dev", - "dev:notification": "bun run --filter @quickbite/notification dev" + "dev:notification": "bun run --filter @quickbite/notification dev", + "dev:frontend": "bun run --filter @quickbite/frontend dev" }, "devDependencies": { "@types/bun": "^1.1.0",