From 60c9e316d9757786bd83ef21e2dc19676bad4c44 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 1 Sep 2026 01:17:51 -0700 Subject: [PATCH 1/5] feat: hosted lead-capture forms end to end: drag-and-drop builder with field settings, design panel, embed/share and submissions tabs in the dashboard, a public TanStack form app (forms/) served by the new standalone forms service (cmd/forms + internal/formserver) on FORMS_DOMAIN with per-form frame-ancestors CSP, honeypot/fill-time/Turnstile/per-IP submit protection and a same-origin JSON API proxying the backend internal API, form submissions creating contacts with categories and campaign enrollment plus realtime, audit, webhook and org-transfer coverage, migration 000114, seed forms, CI jobs, Dockerfile, systemd/nginx/compose manifests and docs --- .github/workflows/build-push.yml | 13 +- .github/workflows/ci.yml | 29 +- AGENTS.md | 7 +- Makefile | 33 +- cmd/backend/main.go | 9 + cmd/forms/main.go | 77 + deploy/config/env.example | 20 + deploy/docker/forms.Dockerfile | 49 + deploy/nginx/warmbly.conf | 27 +- deploy/systemd/README.md | 1 + deploy/systemd/warmbly-forms.service | 23 + docker-compose.yml | 36 +- docs/content/docs/api/endpoints.mdx | 15 + docs/content/docs/api/reference/webhooks.mdx | 1 + .../content/docs/development/architecture.mdx | 1 + docs/content/docs/development/bare-metal.mdx | 34 +- .../docs/development/configuration.mdx | 15 + .../docs/development/deployment-guide.mdx | 8 +- .../docs/development/local-development.mdx | 8 +- docs/content/docs/guides/forms.mdx | 88 + docs/content/docs/guides/meta.json | 1 + .../docs/guides/workspace-export-import.mdx | 2 +- forms/.gitignore | 15 + forms/eslint.config.js | 36 + forms/index.html | 13 + forms/package.json | 35 + forms/pnpm-lock.yaml | 2232 +++++++++++++++++ forms/pnpm-workspace.yaml | 5 + forms/src/FormPage.tsx | 53 + forms/src/FormRenderer.tsx | 201 ++ forms/src/NotFound.tsx | 20 + forms/src/Turnstile.tsx | 47 + forms/src/api.ts | 111 + forms/src/design.ts | 105 + forms/src/embed.ts | 49 + forms/src/fields.tsx | 140 ++ forms/src/main.tsx | 41 + forms/src/styles.css | 229 ++ forms/src/turnstile.ts | 39 + forms/tsconfig.app.json | 27 + forms/tsconfig.json | 7 + forms/tsconfig.node.json | 25 + forms/vite.config.ts | 18 + internal/api/handler/form.go | 183 ++ internal/api/handler/handler.go | 2 + internal/api/handler/internal_form.go | 88 + internal/api/routes.go | 22 + internal/app/form/service.go | 505 ++++ internal/app/form/service_test.go | 103 + internal/app/instanceconfig/entries.go | 18 + internal/app/orgtransfer/spec.go | 14 + internal/config/endpoints.go | 26 + internal/config/providers.go | 7 + internal/formserver/abuse.go | 88 + internal/formserver/client.go | 187 ++ internal/formserver/pages.go | 41 + internal/formserver/server.go | 261 ++ internal/formserver/server_test.go | 252 ++ internal/formserver/static/forms-embed.js | 129 + internal/formwire/formwire.go | 43 + .../db/migrations/000114_forms.down.sql | 9 + .../db/migrations/000114_forms.up.sql | 69 + internal/infrastructure/pubsub/events.go | 40 + internal/models/audit.go | 1 + internal/models/contact.go | 8 +- internal/models/crm.go | 1 + internal/models/form.go | 448 ++++ internal/models/nullable.go | 25 + internal/models/segment.go | 2 +- internal/models/webhook.go | 5 + internal/repository/form_live_test.go | 137 + internal/repository/pg_contact.go | 5 +- internal/repository/pg_form.go | 365 +++ .../lib/realtime_web/channels/org_channel.ex | 4 + web/src/app/app/forms/[id]/page.tsx | 32 + web/src/app/app/forms/page.tsx | 274 ++ .../app/contacts/contact-edit/ActivityTab.tsx | 17 + web/src/components/app/forms/DesignPanel.tsx | 162 ++ .../app/forms/FieldSettingsPanel.tsx | 181 ++ web/src/components/app/forms/FormBuilder.tsx | 498 ++++ web/src/components/app/forms/FormPreview.tsx | 371 +++ .../components/app/forms/SettingsPanel.tsx | 162 ++ web/src/components/app/forms/ShareTab.tsx | 87 + .../components/app/forms/SubmissionsTab.tsx | 228 ++ web/src/components/app/forms/fieldCatalog.ts | 79 + .../app/segments/SegmentPickers.tsx | 1 + web/src/components/layout/AppNav.tsx | 2 + web/src/hooks/useRealtimeEvents.ts | 9 + web/src/lib/api/client/app/forms/index.ts | 47 + web/src/lib/api/hooks/app/forms/index.ts | 75 + .../api/models/app/contacts/ContactDetail.ts | 3 +- .../app/contacts/ContactTimelineEvent.ts | 3 + web/src/lib/api/models/app/forms/Form.ts | 131 + web/src/main.tsx | 9 + 94 files changed, 9368 insertions(+), 36 deletions(-) create mode 100644 cmd/forms/main.go create mode 100644 deploy/docker/forms.Dockerfile create mode 100644 deploy/systemd/warmbly-forms.service create mode 100644 docs/content/docs/guides/forms.mdx create mode 100644 forms/.gitignore create mode 100644 forms/eslint.config.js create mode 100644 forms/index.html create mode 100644 forms/package.json create mode 100644 forms/pnpm-lock.yaml create mode 100644 forms/pnpm-workspace.yaml create mode 100644 forms/src/FormPage.tsx create mode 100644 forms/src/FormRenderer.tsx create mode 100644 forms/src/NotFound.tsx create mode 100644 forms/src/Turnstile.tsx create mode 100644 forms/src/api.ts create mode 100644 forms/src/design.ts create mode 100644 forms/src/embed.ts create mode 100644 forms/src/fields.tsx create mode 100644 forms/src/main.tsx create mode 100644 forms/src/styles.css create mode 100644 forms/src/turnstile.ts create mode 100644 forms/tsconfig.app.json create mode 100644 forms/tsconfig.json create mode 100644 forms/tsconfig.node.json create mode 100644 forms/vite.config.ts create mode 100644 internal/api/handler/form.go create mode 100644 internal/api/handler/internal_form.go create mode 100644 internal/app/form/service.go create mode 100644 internal/app/form/service_test.go create mode 100644 internal/formserver/abuse.go create mode 100644 internal/formserver/client.go create mode 100644 internal/formserver/pages.go create mode 100644 internal/formserver/server.go create mode 100644 internal/formserver/server_test.go create mode 100644 internal/formserver/static/forms-embed.js create mode 100644 internal/formwire/formwire.go create mode 100644 internal/infrastructure/db/migrations/000114_forms.down.sql create mode 100644 internal/infrastructure/db/migrations/000114_forms.up.sql create mode 100644 internal/models/form.go create mode 100644 internal/repository/form_live_test.go create mode 100644 internal/repository/pg_form.go create mode 100644 web/src/app/app/forms/[id]/page.tsx create mode 100644 web/src/app/app/forms/page.tsx create mode 100644 web/src/components/app/forms/DesignPanel.tsx create mode 100644 web/src/components/app/forms/FieldSettingsPanel.tsx create mode 100644 web/src/components/app/forms/FormBuilder.tsx create mode 100644 web/src/components/app/forms/FormPreview.tsx create mode 100644 web/src/components/app/forms/SettingsPanel.tsx create mode 100644 web/src/components/app/forms/ShareTab.tsx create mode 100644 web/src/components/app/forms/SubmissionsTab.tsx create mode 100644 web/src/components/app/forms/fieldCatalog.ts create mode 100644 web/src/lib/api/client/app/forms/index.ts create mode 100644 web/src/lib/api/hooks/app/forms/index.ts create mode 100644 web/src/lib/api/models/app/forms/Form.ts diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 6f8b3170a..2419e25f3 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: service: - description: "Service to build (all, backend, consumer, worker, tracking, realtime)" + description: "Service to build (all, backend, consumer, worker, forms, tracking, realtime)" required: false default: "all" @@ -54,6 +54,13 @@ jobs: - 'internal/**' - 'cmd/worker/**' - 'deploy/docker/worker.Dockerfile' + forms: + - 'go.mod' + - 'go.sum' + - 'internal/**' + - 'cmd/forms/**' + - 'forms/**' + - 'deploy/docker/forms.Dockerfile' tracking: - 'tracking/**' realtime: @@ -67,6 +74,7 @@ jobs: BACKEND: ${{ steps.filter.outputs.backend }} CONSUMER: ${{ steps.filter.outputs.consumer }} WORKER: ${{ steps.filter.outputs.worker }} + FORMS: ${{ steps.filter.outputs.forms }} TRACKING: ${{ steps.filter.outputs.tracking }} REALTIME: ${{ steps.filter.outputs.realtime }} run: | @@ -74,7 +82,7 @@ jobs: native="" if [ "$EVENT" = "workflow_dispatch" ]; then sel="${SELECTED:-all}" - for s in backend consumer worker; do + for s in backend consumer worker forms; do if [ "$sel" = "all" ] || [ "$sel" = "$s" ]; then go="$go\"$s\","; fi done for s in tracking realtime; do @@ -84,6 +92,7 @@ jobs: if [ "$BACKEND" = "true" ]; then go="$go\"backend\","; fi if [ "$CONSUMER" = "true" ]; then go="$go\"consumer\","; fi if [ "$WORKER" = "true" ]; then go="$go\"worker\","; fi + if [ "$FORMS" = "true" ]; then go="$go\"forms\","; fi if [ "$TRACKING" = "true" ]; then native="$native\"tracking\","; fi if [ "$REALTIME" = "true" ]; then native="$native\"realtime\","; fi fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff8b41cf9..fb917c7b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: web: ${{ steps.filter.outputs.web }} admin: ${{ steps.filter.outputs.admin }} site: ${{ steps.filter.outputs.site }} + forms: ${{ steps.filter.outputs.forms }} make: ${{ steps.filter.outputs.make }} ios: ${{ steps.filter.outputs.ios }} steps: @@ -58,6 +59,8 @@ jobs: - 'admin/**' site: - 'site/**' + forms: + - 'forms/**' make: - 'integrations/make/**' ios: @@ -193,6 +196,30 @@ jobs: # in package.json today. run: pnpm build + forms-ci: + name: Forms CI + needs: changes + if: needs.changes.outputs.forms == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: forms + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-pnpm + with: + working-directory: forms + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Build + run: pnpm build + make-ci: name: Make App CI needs: changes @@ -362,7 +389,7 @@ jobs: ci-status: name: CI Status runs-on: ubuntu-latest - needs: [changes, migrations-ci, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images] + needs: [changes, migrations-ci, go-ci, web-ci, admin-ci, site-ci, forms-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images] if: always() steps: - name: Check CI status diff --git a/AGENTS.md b/AGENTS.md index 747539412..735c59b0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,11 +105,12 @@ Event codec: `CODEC_PROVIDER=json` is required wherever workers are exercised (t Infra runs in docker; the Go services and frontends run natively on the host for fast iteration — no docker image rebuilds when you change app code. Targets live in the `Makefile`. -- `make dev` — the one-command stack: brings up the docker infra and waits for postgres, applies migrations, loads seed fixtures (skip with `SEED=false`), installs web + admin deps on first run, starts realtime and tracking as containers, then runs backend + consumer + worker + dashboard + admin in one terminal. Login: dev@warmbly.com / password123, with the emailed login code in Mailpit at http://localhost:18025. Ctrl-C stops the app; infra stays up. +- `make dev` — the one-command stack: brings up the docker infra and waits for postgres, applies migrations, loads seed fixtures (skip with `SEED=false`), installs web + admin deps on first run, starts realtime and tracking as containers, then runs backend + forms + consumer + worker + dashboard + admin in one terminal. Login: dev@warmbly.com / password123, with the emailed login code in Mailpit at http://localhost:18025. Ctrl-C stops the app; infra stays up. - `make infra` — start the backing services in docker (postgres, redis, nats, mailpit). Run once; leave running. Kafka, Schema Registry, localstack, cloud-tasks, and stripe-mock are gone; the stack is no-cloud by default (NATS, local KMS, filesystem blobs, in-process tasks). - `make backend` — run the API natively on `:8080` (applies the embedded migrations on boot against the docker postgres). - `make consumer` / `make worker` / `make worker-premium` — run those Go services natively, each in its own terminal. Two native workers exist because tier placement is strict: free-trial orgs place onto the free-tier worker (`make worker`), paid orgs onto the premium one (`make worker-premium`). The workers read encrypted DEKs through the backend's `/internal/dek` endpoint (the prod `http` provider, no worker DB), so `make backend` must be running and their `INTERNAL_API_TOKEN` must match (the targets are pre-wired to match). -- `make run` — backend + consumer + both workers together in one terminal (Ctrl-C stops all). +- `make run` — backend + forms + consumer + worker together in one terminal (Ctrl-C stops all). +- `make forms` — the public forms service natively on `:8090` (`cmd/forms`): builds the `forms/` TanStack app, then serves it plus the embed loader and public submissions. No database; it resolves forms and forwards submissions through the backend's internal API, so `make backend` must be running and `INTERNAL_API_TOKEN` must match (pre-wired). The backend's `FORMS_DOMAIN=localhost:8090` makes dashboard share links point at it; `make forms FORMS_PORT=8091` (matched on `make backend`) moves it when worktrees share the machine. `make forms-web` runs the Vite dev server (:5175) for iterating on the app itself. - `make sandbox` — fully working demo environment: seeds the "Sunrise Labs" showcase org (live mailboxes: SMTP -> mailpit, IMAP -> dovecot, credentials sealed with `CREDENTIALS_ENCRYPTION_KEY`) and runs the simulator that plays the internet (delivers mail into dovecot inboxes, opens pixels, clicks tracked links, replies as contacts). Needs `make run` + `make tracking` alongside. Docs: `docs/content/docs/development/sandbox.mdx`. - `make tracking` / `make realtime` — the Rust tracking pixel service (:3000) and Elixir/Phoenix websocket fanout (:4000). Deliberately kept out of `make run`; start them only when needed, and only if you have the cargo / elixir toolchains on the host. - `make web` / `make admin` / `make site` — frontend dev servers (5173 / 5174 / 4321), pointed at the native backend. @@ -195,6 +196,8 @@ API keys with the `REALTIME_SUBSCRIBE` permission (bit 11) can connect to the sa - `cmd/backend`: API and business orchestration - `cmd/consumer`: consumes Kafka events and updates platform state - `cmd/worker`: execution worker for send/sync operations +- `cmd/forms`: the public face of hosted lead-capture forms (`internal/formserver`): serves the built `forms/` app, per-form page shells (with the per-form embed CSP), the embed loader and public submissions on their own origin (`FORMS_DOMAIN`). No database; the backend's internal API is its only dependency, like the tracking service +- `forms/`: the public form page app (React + TanStack Router/Query/Form, Vite CSR build). Renders a published form from the same-origin `/api/forms/:publicID`, submits to `/api/forms/:publicID/submit`; the Go forms service hosts the build - `tracking/`: open and click tracking service - `realtime/`: websocket fanout service - `web/`: in-product frontend (dashboard). Customer-facing only: it holds no platform-admin screens, and operator tooling must not be added back here diff --git a/Makefile b/Makefile index e4445e6ec..abd3ad11e 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go .PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations proto check-proto \ up claim doctor cli seed-demo seed seed-plan sandbox sandbox-seed sandbox-simulate reset logs status stop down test-seed \ restart restart-go restart-all infra infra-down app app-down app-logs \ - backend consumer worker run dev tracking realtime web \ + backend forms forms-web consumer worker run dev tracking realtime web \ admin site docs grant-admin revoke-admin gen-key db-reset db-wipe migrate setup-tools: @@ -558,6 +558,7 @@ backend: $(GO_DEV_ENV) \ $(AI_DEV_ENV) \ API_HOST=0.0.0.0:8080 \ + FORMS_DOMAIN=localhost:$(FORMS_PORT) \ GIN_MODE=debug \ APP_URL=http://$(WEB_HOST):5173 \ CORS_ALLOW_ORIGINS=$(CORS_ORIGINS) \ @@ -575,6 +576,30 @@ backend: INTERNAL_API_TOKEN=local-dev-internal-token \ go run ./cmd/backend +# Public forms service on :8090 (cmd/forms): serves the TanStack form app +# (forms/), per-form page shells, the embed loader and public submissions. +# Builds the app first (fast), then runs the Go service; it resolves forms +# and forwards submissions through the backend's internal API, so +# `make backend` must be running and INTERNAL_API_TOKEN must match +# (pre-wired here, like the workers). +# Override the port (here AND on make backend, so share links agree) with +# `make forms FORMS_PORT=8091` when worktrees share the machine. +FORMS_PORT ?= 8090 +forms: + @if [ ! -d forms/node_modules ]; then echo "Installing forms dependencies (first run)..."; cd forms && pnpm install; fi + @cd forms && pnpm build --logLevel warn + GIN_MODE=debug \ + FORMS_PORT=$(FORMS_PORT) \ + FORMS_STATIC_DIR=forms/dist \ + BACKEND_INTERNAL_URL=http://localhost:8080 \ + INTERNAL_API_TOKEN=local-dev-internal-token \ + go run ./cmd/forms + +# Vite dev server for the forms app (:5175, /api proxied to the Go service) +# when you want hot reload while editing forms/ itself. +forms-web: + cd forms && FORMS_API_URL=http://localhost:$(FORMS_PORT) pnpm dev + # Event consumer (NATS by default; Kafka with -tags kafka) -> postgres. consumer: $(GO_DEV_ENV) \ @@ -602,9 +627,10 @@ worker: # Workers are interchangeable now; run a second `make worker WORKER_ID=` # in another terminal to add parallelism. run: - @echo "backend + consumer + worker (native). Ctrl-C stops all. Run 'make infra' first if infra is down." + @echo "backend + forms + consumer + worker (native). Ctrl-C stops all. Run 'make infra' first if infra is down." @trap 'kill 0' INT TERM; \ $(MAKE) --no-print-directory backend & \ + $(MAKE) --no-print-directory forms & \ $(MAKE) --no-print-directory consumer & \ $(MAKE) --no-print-directory worker & \ wait @@ -671,11 +697,12 @@ dev: @echo "Starting realtime + tracking as containers (no host Elixir/cargo needed)..." @BACKEND_INTERNAL_URL=http://host.docker.internal:8080 $(COMPOSE) up -d --build realtime tracking @echo "" - @echo "Starting backend + consumer + worker + dashboard + admin. Ctrl-C stops them (infra stays up)." + @echo "Starting backend + forms + consumer + worker + dashboard + admin. Ctrl-C stops them (infra stays up)." @echo "Dashboard: $(DASHBOARD_URL) Admin: $(ADMIN_URL) Login: dev@warmbly.com / password123" @echo "" @trap 'kill 0' INT TERM; \ $(MAKE) --no-print-directory backend & \ + $(MAKE) --no-print-directory forms & \ $(MAKE) --no-print-directory consumer & \ $(MAKE) --no-print-directory worker & \ $(MAKE) --no-print-directory web & \ diff --git a/cmd/backend/main.go b/cmd/backend/main.go index e06623782..57f9383c5 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -52,6 +52,7 @@ import ( emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify" "github.com/warmbly/warmbly/internal/app/feature" "github.com/warmbly/warmbly/internal/app/fleet" + "github.com/warmbly/warmbly/internal/app/form" "github.com/warmbly/warmbly/internal/app/group" "github.com/warmbly/warmbly/internal/app/guardrail" idempotencyapp "github.com/warmbly/warmbly/internal/app/idempotency" @@ -164,6 +165,7 @@ func main() { var sequenceService sequence.SequenceService var contactService contact.ContactService var segmentService segment.Service + var formService form.Service var websiteTrackingService websitetracking.Service var socketService socket.SocketService var uniboxService unibox.UniboxService @@ -1195,6 +1197,12 @@ func main() { contactService = contact.NewService(contactRepostory, subscriptionRepository, planRepository, streamingPublisher) segmentRepository := repository.NewSegmentRepository(primaryDB) segmentService = segment.NewService(segmentRepository, contactRepostory) + formRepository := repository.NewFormRepository(primaryDB) + formService = form.NewService(formRepository) + formService.SetContacts(contactService) + formService.SetRealtime(streamingPublisher) + formService.SetCaptcha(captcha) + formService.SetWebhooks(webhookServiceForHandler) // A visibly bad import is filed on the workspace's posture. On its own // it can only reach `watch`, which changes nothing. if aware, ok := contactService.(contact.OrgRiskAware); ok && orgRiskService != nil { @@ -1830,6 +1838,7 @@ func main() { RateLimitService: rateLimitService, ContactService: contactService, SegmentService: segmentService, + FormService: formService, SequenceService: sequenceService, UniboxService: uniboxService, diff --git a/cmd/forms/main.go b/cmd/forms/main.go new file mode 100644 index 000000000..b73184b89 --- /dev/null +++ b/cmd/forms/main.go @@ -0,0 +1,77 @@ +// The forms service: the public face of hosted lead-capture forms. Serves +// /f/ pages, the /forms.js embed loader and public submissions on +// its own port, so form traffic never touches the API origin. No Postgres, +// no Redis, no event bus: the backend's internal API (BACKEND_INTERNAL_URL + +// INTERNAL_API_TOKEN, the same pair the tracking service uses) is its only +// dependency. +package main + +import ( + "log" + "os" + "strconv" + "strings" + + "github.com/warmbly/warmbly/internal/formserver" +) + +func main() { + backendURL := strings.TrimSpace(os.Getenv("BACKEND_INTERNAL_URL")) + if backendURL == "" { + log.Fatal("BACKEND_INTERNAL_URL is required (the backend's internal API base, e.g. http://localhost:8080)") + } + token := os.Getenv("INTERNAL_API_TOKEN") + if token == "" { + log.Fatal("INTERNAL_API_TOKEN is required (must match the backend's)") + } + + submitLimit := 0 + if v := os.Getenv("FORM_IP_RATE_LIMIT"); v != "" { + parsed, err := strconv.Atoi(v) + if err != nil || parsed <= 0 { + log.Fatalf("invalid FORM_IP_RATE_LIMIT %q", v) + } + submitLimit = parsed + } + + staticDir := os.Getenv("FORMS_STATIC_DIR") + if staticDir == "" { + // The repo-relative default fits `make forms` (run from the root) and + // the bare-metal layout (WorkingDirectory=/opt/warmbly). + staticDir = "forms/dist" + } + + srv, err := formserver.New(formserver.Config{ + BackendURL: backendURL, + InternalToken: token, + StaticDir: staticDir, + SubmitLimit: submitLimit, + }) + if err != nil { + log.Fatal(err) + } + + r, err := srv.Router(splitCSV(os.Getenv("TRUSTED_PROXIES"))) + if err != nil { + log.Fatalf("invalid TRUSTED_PROXIES: %v", err) + } + + port := os.Getenv("FORMS_PORT") + if port == "" { + port = "8090" + } + log.Printf("forms service listening on :%s (backend %s)", port, backendURL) + if err := r.Run(":" + port); err != nil { + log.Fatal(err) + } +} + +func splitCSV(v string) []string { + var out []string + for _, part := range strings.Split(v, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/deploy/config/env.example b/deploy/config/env.example index 11644525a..8160327a7 100644 --- a/deploy/config/env.example +++ b/deploy/config/env.example @@ -85,6 +85,9 @@ BILLING_PROVIDER=none # + TURNSTILE_SECRET to require Cloudflare Turnstile on auth endpoints. # CAPTCHA_PROVIDER=none # TURNSTILE_SECRET= +# TURNSTILE_SITE_KEY is the public widget key the hosted form pages render +# with when a form enables spam protection (the SPAs carry their own copy). +# TURNSTILE_SITE_KEY= # === Backend API === API_HOST=0.0.0.0:8080 # binds all interfaces already @@ -103,6 +106,11 @@ APP_URL=http://localhost:5173 CORS_ALLOW_ORIGINS=http://localhost:5173,http://localhost:5174 WEBSOCKET_URL=ws://localhost:4000/socket/websocket ENCRYPTED_KEYS_PROVIDER=postgres # backend/consumer: postgres; workers: http (below) +# Hosted forms are served by the forms service (see its section below). +# FORMS_DOMAIN is the public host routed to it (deploy/nginx/warmbly.conf); +# the backend builds share links and embed codes from it. Unset leaves forms +# without a public URL. +# FORMS_DOMAIN=forms.example.com GEODB_PATH=/app/data/GeoLite2-City.mmdb # optional everywhere; missing = no location labels # === Mailbox connections === @@ -237,6 +245,18 @@ TRACKING_PAGEHIT_RATE_LIMIT_PER_MIN=60 # cf-connecting-ip behind Cloudflare). Nothing else is read. # TRACKING_CLIENT_IP_HEADER=x-forwarded-for +# === Forms service (hosted form pages, embeds, submissions) === +# Its own process (cmd/forms) on FORMS_PORT, so form traffic never touches the +# API origin. It serves the built forms/ app (FORMS_STATIC_DIR) and holds no +# database: it resolves forms and forwards submissions through the backend +# internal API (BACKEND_INTERNAL_URL + INTERNAL_API_TOKEN, the same pair the +# tracking service uses). TRUSTED_PROXIES above applies here too, so the +# submit limiter sees the real visitor address behind nginx. +FORMS_PORT=8090 +# FORMS_STATIC_DIR=forms/dist +# Public form submissions allowed per source IP per 10 minutes. +# FORM_IP_RATE_LIMIT=30 + # === Realtime service (Elixir/Phoenix) === PHX_HOST=localhost PORT=4000 diff --git a/deploy/docker/forms.Dockerfile b/deploy/docker/forms.Dockerfile new file mode 100644 index 000000000..2e8d0fc9b --- /dev/null +++ b/deploy/docker/forms.Dockerfile @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1.7 +# +# The forms service: the public face of hosted lead-capture forms. The React +# (TanStack) app in forms/ builds to static files; the Go binary serves them, +# the per-form page shells and the same-origin submit API. Always CGO-free — +# no event bus, no database, no cache; the backend's internal API is its only +# dependency. +# Builders run on $BUILDPLATFORM and cross-compile / emit static assets for +# $TARGETARCH (no QEMU). +FROM --platform=$BUILDPLATFORM node:22-alpine AS appbuilder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@11.9.0 --activate +COPY forms/package.json forms/pnpm-lock.yaml forms/pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY forms/ . +RUN pnpm build + +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder + +ARG TARGETOS TARGETARCH +RUN apk add --no-cache git ca-certificates + +WORKDIR /app +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o /out/forms ./cmd/forms + +# Runtime stage +FROM alpine:3.23 + +RUN apk add --no-cache ca-certificates tzdata && \ + adduser -D -u 1000 warmbly + +COPY --from=builder /out/forms /app/forms +COPY --from=appbuilder /app/dist /app/static +ENV FORMS_STATIC_DIR=/app/static + +USER warmbly +EXPOSE 8090 + +# 127.0.0.1, not localhost: busybox wget tries ::1 first but the server binds IPv4. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8090/health || exit 1 + +ENTRYPOINT ["/app/forms"] diff --git a/deploy/nginx/warmbly.conf b/deploy/nginx/warmbly.conf index e086c82f9..bd605f9e8 100644 --- a/deploy/nginx/warmbly.conf +++ b/deploy/nginx/warmbly.conf @@ -1,7 +1,8 @@ # nginx site for a Docker-free Warmbly install: the two static frontends plus -# reverse proxies for the API, the websocket, and the tracking service. +# reverse proxies for the API, the websocket, the tracking service, and the +# forms service. # Replace example.com. Every server block reads one certificate covering all -# five names from /etc/letsencrypt/live/app.example.com/, so issue it first +# six names from /etc/letsencrypt/live/app.example.com/, so issue it first # (certbot certonly --standalone ... --cert-name app.example.com) or nginx # refuses the site. The proxied services bind localhost, so nothing but nginx # is reachable. @@ -98,8 +99,28 @@ server { } } +# Hosted forms (forms service :8090). Its own process, so form traffic never +# touches the API origin: FORMS_DOMAIN=forms.example.com in warmbly.env makes +# share links and embed codes point here, and TRUSTED_PROXIES=127.0.0.1/32 +# lets the service rate-limit on the real visitor address. The service only +# serves the public form routes and /health; there is no API to hide. +server { + listen 443 ssl http2; + server_name forms.example.com; + ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + server { listen 80; - server_name app.example.com admin.example.com api.example.com ws.example.com t.example.com; + server_name app.example.com admin.example.com api.example.com ws.example.com t.example.com forms.example.com; return 301 https://$host$request_uri; } diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md index cffab9a79..fcfc01261 100644 --- a/deploy/systemd/README.md +++ b/deploy/systemd/README.md @@ -7,6 +7,7 @@ them is [Deploying without Docker](https://docs.warmbly.com/development/bare-met | Unit | Runs | Reads | |------|------|-------| | `warmbly-backend.service` | `/opt/warmbly/bin/backend` | `/etc/warmbly/warmbly.env` | +| `warmbly-forms.service` | `/opt/warmbly/bin/forms` | `/etc/warmbly/warmbly.env` | | `warmbly-consumer.service` | `/opt/warmbly/bin/consumer` | `/etc/warmbly/warmbly.env` | | `warmbly-tracking.service` | `/opt/warmbly/bin/tracking` | `/etc/warmbly/warmbly.env` | | `warmbly-realtime.service` | `/opt/warmbly/realtime/bin/realtime start` | `/etc/warmbly/warmbly.env` | diff --git a/deploy/systemd/warmbly-forms.service b/deploy/systemd/warmbly-forms.service new file mode 100644 index 000000000..3f8332b83 --- /dev/null +++ b/deploy/systemd/warmbly-forms.service @@ -0,0 +1,23 @@ +[Unit] +Description=Warmbly forms service (hosted form pages, embeds and submissions) +Documentation=https://docs.warmbly.com/development/bare-metal/ +After=network-online.target warmbly-backend.service +Wants=network-online.target + +[Service] +Type=simple +User=warmbly +Group=warmbly +EnvironmentFile=/etc/warmbly/warmbly.env +WorkingDirectory=/opt/warmbly +ExecStart=/opt/warmbly/bin/forms +Restart=always +RestartSec=3 +LimitNOFILE=65536 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml index 936497870..e2cfc416f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,9 +81,15 @@ x-selfhost-env: &selfhost-env STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-} - # Captcha. Set both to turn Turnstile on. + # Captcha. Set both to turn Turnstile on. The site key also lets hosted + # form pages render the widget when a form enables spam protection. CAPTCHA_PROVIDER: ${CAPTCHA_PROVIDER:-none} TURNSTILE_SECRET: ${TURNSTILE_SECRET:-} + TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-} + + # Hosted forms. The public host routed to the forms service; share links + # and embed codes point here. Unset leaves forms without a public URL. + FORMS_DOMAIN: ${FORMS_DOMAIN:-} # Deployment mode. Picks the auth defaults below; every one stays # individually overridable. @@ -347,6 +353,34 @@ services: retries: 10 start_period: 20s + # Public face of hosted forms: pages, embed loader, submissions. No + # database; the backend's internal API is its only dependency. + forms: + restart: unless-stopped + build: + context: . + dockerfile: deploy/docker/forms.Dockerfile + ports: ["${FORMS_PORT:-8090}:8090"] + environment: + GIN_MODE: release + FORMS_PORT: "8090" + FORM_IP_RATE_LIMIT: ${FORM_IP_RATE_LIMIT:-} + TRUSTED_PROXIES: ${FORMS_TRUSTED_PROXIES:-} + # Overridable so a native `make backend` can be pointed at from a + # container via host.docker.internal, mirroring the tracking service. + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL:-http://backend:8080} + INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-local-dev-internal-token} + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + backend: { condition: service_healthy } + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8090/health"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + consumer: restart: unless-stopped build: diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 1c36515ae..144554236 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -122,6 +122,21 @@ AI contact research charges credits (2 per run, billable even when it finds noth Segments are saved contact audiences: a condition list plus per-contact manual overrides, evaluated live. Contact scopes cover them because a segment is a view over contacts; enrolling one into a campaign writes leads, so that call takes the campaign write scope. `POST /contacts/search` and `POST /contacts/export` accept `segment_ids` to scope any contact query to a segment. See [contacts](/api/reference/contacts/#segments) for the condition format. +### Forms + +| Method | Path | API Permission | +|--------|------|----------------| +| GET | `/forms` | `READ_CONTACTS` | +| GET | `/forms/config` | `READ_CONTACTS` | +| POST | `/forms` | `WRITE_CONTACTS` | +| GET | `/forms/:id` | `READ_CONTACTS` | +| PATCH | `/forms/:id` | `WRITE_CONTACTS` | +| DELETE | `/forms/:id` | `WRITE_CONTACTS` | +| GET | `/forms/:id/submissions` | `READ_CONTACTS` | +| DELETE | `/forms/:id/submissions/:sid` | `WRITE_CONTACTS` | + +Hosted lead-capture forms; see the [forms guide](/guides/forms/). Contact scopes cover them because a form exists to create contacts. The public page (`/f/:public_id`), its submit endpoint and the embed loader (`/forms.js`) are served by the standalone forms service on its own host (`FORMS_DOMAIN`), not the API origin, and take no authentication; the unguessable public id is the capability. + ### Lead sync Saved Google Sheets sources that upsert contacts on demand. Gated under the contact write scope because a sync ultimately writes contacts. Nothing syncs on a timer: a source only runs when `/lead-sync/sources/:id/sync` is called. diff --git a/docs/content/docs/api/reference/webhooks.mdx b/docs/content/docs/api/reference/webhooks.mdx index c29b2b63d..8386afa05 100644 --- a/docs/content/docs/api/reference/webhooks.mdx +++ b/docs/content/docs/api/reference/webhooks.mdx @@ -510,6 +510,7 @@ The full catalog is available at `GET /webhooks/event-types`. Below is the compl | `contact.created` | A contact is created. | | | `contact.updated` | A contact's fields change. | | | `contact.deleted` | A contact is deleted. | | +| `form.submitted` | A hosted form received a submission. Carries the form id and name, the submission id, the answers, and the contact id when one was created or matched. | | ### CRM diff --git a/docs/content/docs/development/architecture.mdx b/docs/content/docs/development/architecture.mdx index 1d28821d3..e3bf01dec 100644 --- a/docs/content/docs/development/architecture.mdx +++ b/docs/content/docs/development/architecture.mdx @@ -38,6 +38,7 @@ flowchart LR | Backend | Go (Gin) | Control | REST API, auth, business logic, worker orchestration | | Consumer | Go | Control | Event bus processor → Postgres | | Tracking | Rust (Axum) | Control | Open/click pixels and redirects → event bus | +| Forms | Go (Gin) + React (TanStack) | Control | Hosted form pages (`forms/` app), embeds and public submissions → backend internal API | | Realtime | Elixir (Phoenix) | Control | WebSocket fanout | | Worker | Go | Execution | One per VPS; subscribes to a per-worker event bus topic; never opens a Postgres connection | | Web | React (Vite) | n/a | Dashboard frontend | diff --git a/docs/content/docs/development/bare-metal.mdx b/docs/content/docs/development/bare-metal.mdx index bc8646579..8e54014db 100644 --- a/docs/content/docs/development/bare-metal.mdx +++ b/docs/content/docs/development/bare-metal.mdx @@ -15,12 +15,14 @@ flowchart LR NG --> API["backend :8080"] NG --> RT["realtime :4000"] NG --> TRK["tracking :3000"] + NG --> FRM["forms :8090"] subgraph HOST["One host, systemd"] API --> PG[("PostgreSQL 16")] API --> BUS{{"NATS JetStream"}} API --> RD[("Redis 7")] BUS --> CONS["consumer"] --> PG TRK --> BUS + FRM --> API RT --- RD BUS --> W["worker"] end @@ -38,7 +40,7 @@ flowchart LR | Go 1.25 | Builds the backend, consumer, worker, `migrate` and `warmblyctl` | | Rust (stable, 1.93 or newer) | Builds the tracking service | | Elixir 1.18 on OTP 26 | Builds the realtime service | -| Node 22 and pnpm | Builds the dashboard and admin panel | +| Node 22 and pnpm | Builds the dashboard, admin panel and forms app | | nginx | Serves the static frontends and terminates TLS for the API, websocket and tracking hosts | | Five DNS names | `app`, `admin`, `api`, `ws` and `t` under your domain, all pointing at the host | @@ -145,15 +147,15 @@ Checkout a release tag rather than `main` when you want a known version: `git -C ### Go services -Five binaries come out of one module. Static builds, so the production host needs no Go runtime: +Six binaries come out of one module. Static builds, so the production host needs no Go runtime: ```bash cd /opt/warmbly/src export CGO_ENABLED=0 -for cmd in backend consumer worker migrate warmblyctl; do +for cmd in backend forms consumer worker migrate warmblyctl; do go build -ldflags="-s -w" -o "out/$cmd" "./cmd/$cmd" done -sudo install -m 0755 out/backend out/consumer out/worker out/migrate out/warmblyctl /opt/warmbly/bin/ +sudo install -m 0755 out/backend out/forms out/consumer out/worker out/migrate out/warmblyctl /opt/warmbly/bin/ sudo ln -sf /opt/warmbly/bin/warmblyctl /usr/local/bin/warmblyctl ``` @@ -198,16 +200,19 @@ Build on a machine with the same libc as the host: a release built on Alpine (mu -### Dashboard and admin panel +### Dashboard, admin panel and forms app -Both are static Vite builds. No URL is baked in at build time; the frontends read `config.js` at runtime, which you write in the next step. +All three are static Vite builds. No URL is baked in at build time; the dashboard and admin read `config.js` at runtime (next step), and the forms app is served by the forms binary itself, which looks for it at `forms/dist` under its working directory. ```bash cd /opt/warmbly/src/web && pnpm install --frozen-lockfile && pnpm build cd /opt/warmbly/src/admin && pnpm install --frozen-lockfile && pnpm build -sudo rm -rf /opt/warmbly/web /opt/warmbly/admin +cd /opt/warmbly/src/forms && pnpm install --frozen-lockfile && pnpm build +sudo rm -rf /opt/warmbly/web /opt/warmbly/admin /opt/warmbly/forms sudo cp -r /opt/warmbly/src/web/dist /opt/warmbly/web sudo cp -r /opt/warmbly/src/admin/dist /opt/warmbly/admin +sudo install -d /opt/warmbly/forms +sudo cp -r /opt/warmbly/src/forms/dist /opt/warmbly/forms/dist ``` @@ -440,7 +445,7 @@ To apply migrations without starting the API, for instance in a deploy script, ` ### Start the rest ```bash -sudo systemctl enable --now warmbly-consumer warmbly-tracking warmbly-realtime warmbly-worker +sudo systemctl enable --now warmbly-forms warmbly-consumer warmbly-tracking warmbly-realtime warmbly-worker systemctl status 'warmbly-*' --no-pager ``` @@ -449,6 +454,7 @@ Each answers on its own port: ```bash curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health # backend curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/health # tracking +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/health # forms curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4000/health # realtime ``` @@ -460,13 +466,13 @@ The consumer and worker have no port; `journalctl -u warmbly-worker` shows the w ### Put nginx in front -[`deploy/nginx/warmbly.conf`](https://github.com/warmbly/warmbly/blob/main/deploy/nginx/warmbly.conf) serves the two static frontends and proxies the API, websocket and tracking hosts. It references Let's Encrypt certificate files by path, so the certificates have to exist before nginx will accept the site. Issue them first, in standalone mode with nothing on port 80, then enable the site: +[`deploy/nginx/warmbly.conf`](https://github.com/warmbly/warmbly/blob/main/deploy/nginx/warmbly.conf) serves the two static frontends and proxies the API, websocket, tracking and forms hosts. It references Let's Encrypt certificate files by path, so the certificates have to exist before nginx will accept the site. Issue them first, in standalone mode with nothing on port 80, then enable the site: ```bash sudo systemctl stop nginx sudo certbot certonly --standalone --agree-tos -m you@yourdomain.com \ -d app.yourdomain.com -d admin.yourdomain.com -d api.yourdomain.com \ - -d ws.yourdomain.com -d t.yourdomain.com \ + -d ws.yourdomain.com -d t.yourdomain.com -d forms.yourdomain.com \ --cert-name app.yourdomain.com sudo cp /opt/warmbly/src/deploy/nginx/warmbly.conf /etc/nginx/sites-available/warmbly.conf @@ -475,7 +481,7 @@ sudo ln -s /etc/nginx/sites-available/warmbly.conf /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl start nginx ``` -`--cert-name app.yourdomain.com` puts all five names on one certificate under `/etc/letsencrypt/live/app.yourdomain.com/`, which is the path every server block in the shipped file uses. Renewals run from the timer certbot installs; because the certificate was issued standalone, tell it how to reload nginx once: +`--cert-name app.yourdomain.com` puts all six names on one certificate under `/etc/letsencrypt/live/app.yourdomain.com/`, which is the path every server block in the shipped file uses. Renewals run from the timer certbot installs; because the certificate was issued standalone, tell it how to reload nginx once: ```bash sudo certbot renew --dry-run --pre-hook 'systemctl stop nginx' --post-hook 'systemctl start nginx' @@ -562,7 +568,7 @@ Rebuild the artifacts that changed, then restart. Migrations are forward-only an cd /opt/warmbly/src && sudo git pull # or checkout a newer tag # repeat the build steps for the services that changed, then: sudo systemctl restart warmbly-backend -sudo systemctl restart warmbly-consumer warmbly-tracking warmbly-realtime warmbly-worker +sudo systemctl restart warmbly-forms warmbly-consumer warmbly-tracking warmbly-realtime warmbly-worker ``` Frontend rebuilds overwrite `config.js` when you copy `dist/` over, so rewrite it afterwards (or keep the two files somewhere and copy them back). A build script that does all of it in order is worth writing the second time you upgrade. @@ -602,11 +608,11 @@ Restore into an empty database before the backend starts, then start it and it a ## Quick reference ``` -/opt/warmbly/bin/{backend,consumer,worker,tracking,migrate,warmblyctl} +/opt/warmbly/bin/{backend,forms,consumer,worker,tracking,migrate,warmblyctl} /opt/warmbly/realtime/bin/realtime mix release /opt/warmbly/web, /opt/warmbly/admin static builds + config.js /opt/warmbly/src the checkout (worker installer is served from here) -/etc/warmbly/warmbly.env backend, consumer, tracking, realtime +/etc/warmbly/warmbly.env backend, forms, consumer, tracking, realtime /etc/warmbly/worker.env worker /var/lib/warmbly/blobs BLOB_FS_ROOT /etc/systemd/system/warmbly-*.service from deploy/systemd/ diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index dbb26598f..f8b042bec 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -113,6 +113,7 @@ Every emailed link (password reset, invitation, the first-run claim link) is bui | `PHX_HOST` | The realtime service's own hostname | `localhost` | yes | | `TRACKING_DOMAIN` | The host that serves open pixels and click links, and the `CNAME` value customers point their own tracking subdomain at. Use a separate, neutral domain in production. Unset means campaign mail ships with no pixel and unwrapped links, and no custom tracking domain can verify | `localhost:3000` under compose, otherwise unset | no | | `TRACKING_SERVICE_URL` | Where the backend reaches the tracking service internally | unset | yes | +| `FORMS_DOMAIN` | The host hosted form pages and embeds are served on (`forms.example.com`, routed to the forms service). The backend builds share links and embed codes from it; unset leaves forms without a public URL | unset | no (read per request) | Once `PUBLIC_HOST` is set, every derived URL uses it and `http://localhost:5173` stops working, because a `localhost` origin is no longer in `CORS_ALLOW_ORIGINS`. To keep both, list them yourself in `CORS_ALLOW_ORIGINS`. @@ -172,6 +173,7 @@ Full behavior, including what each registration mode does to the sign-up form, i | `TURNSTILE_SECRET` | Cloudflare Turnstile secret, read by the backend | unset | yes | | `WARMBLY_TURNSTILE_KEY` | The Turnstile site key, read by the dashboard and admin panel at container start | a test key under compose | yes (container start) | | `TURNSTILE_BYPASS_TOKEN` | A token that skips verification. Only honoured when `APP_ENV=dev` | unset | yes | +| `TURNSTILE_SITE_KEY` | The public Turnstile widget key. The backend hands it to the forms service, which renders it on hosted form pages that enable spam protection. The frontends carry their own copy in `WARMBLY_TURNSTILE_KEY` | unset | no (read per request) | `CAPTCHA_PROVIDER` has no constant default. Unset, it resolves to `turnstile` when `TURNSTILE_SECRET` holds a value and to `none` when it does not, so configuring the secret is what turns captcha on and clearing it is what turns captcha off. The panel reports the resolved value with source `derived`. @@ -379,6 +381,19 @@ Delayed sends run through the local poller, so the backend must be running for s | `SENTRY_DSN` | Error reporting. Optional in every environment, including `prod` | unset | | `APNS_KEY` or `APNS_KEY_PATH`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_TOPIC` | Mobile push on backend and consumer. Partial configuration disables push with a warning, never a crash | unset | +## Forms service + +The public face of hosted forms (`cmd/forms`): it serves the React (TanStack) form app built from `forms/`, the per-form page shells, the embed loader and public submissions, on its own port so form traffic never touches the API origin. It reads its own environment; none of these appear in the admin panel except `FORM_IP_RATE_LIMIT`. + +| Variable | What it does | Default | +|---|---|---| +| `FORMS_PORT` | Listen port | `8090` | +| `FORMS_STATIC_DIR` | The built forms app (`pnpm build` output). The service exits at boot when `index.html` is missing there | `forms/dist` | +| `BACKEND_INTERNAL_URL` | Where the service resolves forms and forwards submissions, the same variable the tracking service uses. **Required**: the service exits at boot without it | none | +| `INTERNAL_API_TOKEN` | Bearer token for those calls, matching the backend's. **Required**: the service exits at boot on an empty value | none | +| `FORM_IP_RATE_LIMIT` | Public form submissions allowed per source IP per 10 minutes, per forms-service instance | `30` | +| `TRUSTED_PROXIES` | CIDRs whose `X-Forwarded-For` the service believes, same convention as the backend. Empty trusts nothing and uses the socket peer; set it behind a reverse proxy or the submit limiter throttles the proxy's address instead of the visitor's | empty | + ## Tracking service The Rust open and click service. It reads its own environment, so these have to be set on that container, and none of them appear in the admin panel. diff --git a/docs/content/docs/development/deployment-guide.mdx b/docs/content/docs/development/deployment-guide.mdx index f28f1fcca..523519dde 100644 --- a/docs/content/docs/development/deployment-guide.mdx +++ b/docs/content/docs/development/deployment-guide.mdx @@ -17,6 +17,7 @@ flowchart LR API --> BUS{{"NATS JetStream"}} BUS --> CONS["consumer"] --> PG TRK["tracking :3000"] --> BUS + FRM["forms :8090"] --> API RT["realtime :4000"] --- RD[("Redis")] end BUS --> W1["worker"] @@ -159,13 +160,14 @@ Rotating the secrets means recreating the containers, which `make up` does. Chan make status ``` -`backend`, `postgres`, `redis` and `nats` report `(healthy)`. The backend applies its migrations on boot, so it takes a few seconds longer than the rest. `realtime` and `tracking` have no healthcheck and show as `running`. +`backend`, `forms`, `postgres`, `redis` and `nats` report `(healthy)`. The backend applies its migrations on boot, so it takes a few seconds longer than the rest. `realtime` and `tracking` have no healthcheck and show as `running`. Each entry point answers on its own port: ```bash curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/health # backend curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/health # tracking +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8090/health # forms curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/health # realtime curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5173 # dashboard ``` @@ -181,13 +183,14 @@ The admin panel's **System Status** page shows the same probes plus the platform | web / admin | 5173 / 5174 | Dashboard and admin panel (static nginx builds, URLs injected at container start) | | backend | 8080 | REST API; applies migrations on every boot; `GET /health` | | tracking | 3000 | Open pixels and click redirects; `GET /health` | +| forms | 8090 | Hosted form pages, embeds and public submissions; `GET /health` | | realtime | 4000 | WebSocket fanout; `GET /health` | | consumer / worker | none | Event processor; send and sync executor | | postgres / redis | 15432 / 16379 | PostgreSQL 16; cache and realtime bridge | | nats | 4222 (monitoring 8222) | Event bus (JetStream) | | mailpit | 18025 (SMTP 11025), bound to localhost | Not started by `make up`. An SMTP sink for the demo environment, in the `sandbox` profile. Its UI has no authentication, so it is never published to the network | -Ten containers, idling near 300 MB in total. Nothing that only the demo environment needs is started: the mail catcher and the local IMAP server sit in the `sandbox` profile and come up only when `make sandbox`, `make dev` or `make infra` name them. +Eleven containers, idling near 300 MB in total. Nothing that only the demo environment needs is started: the mail catcher and the local IMAP server sit in the `sandbox` profile and come up only when `make sandbox`, `make dev` or `make infra` name them. There is no separate migration step. Migrations are embedded in the backend binary, and a standalone `/app/migrate` binary ships in the image as well. @@ -219,6 +222,7 @@ These are the ones that break things quietly when they drift, because each servi | consumer | The same shared block as the backend. It writes to Postgres, so it needs `PRIMARY_DB` and the encryption keys | | worker | No database. It needs the event bus, `REDIS`, the encryption keys, `ENCRYPTED_KEYS_BACKEND_URL` plus the internal token, and the `BOX_*` OAuth clients | | tracking | The event bus, `BACKEND_INTERNAL_URL`, and `INTERNAL_API_TOKEN` | +| forms | No database and no event bus: only `BACKEND_INTERNAL_URL` and `INTERNAL_API_TOKEN` | | realtime | `JWT_SECRET`, `SECRET_KEY_BASE`, `DATABASE_URL`, `REDIS_URL`, `PHX_HOST` | | web / admin | Only `WARMBLY_*` URLs, read at container start and written into `/config.js`. The same image runs anywhere | diff --git a/docs/content/docs/development/local-development.mdx b/docs/content/docs/development/local-development.mdx index ea3312efa..7f1acf978 100644 --- a/docs/content/docs/development/local-development.mdx +++ b/docs/content/docs/development/local-development.mdx @@ -67,9 +67,9 @@ iterating on those services themselves. Day-one: ```bash -make dev # one command: infra + migrations + seed + backend/consumer/worker + realtime + tracking + dashboard + admin +make dev # one command: infra + migrations + seed + backend/forms/consumer/worker + realtime + tracking + dashboard + admin make infra # postgres, redis, nats, mailpit (leave running) -make run # backend + consumer + worker (native, after make infra) +make run # backend + forms + consumer + worker (native, after make infra) make up # the whole no-cloud stack in Docker (docker compose up --build) make gen-key # print a KMS_LOCAL_MASTER_KEY for a real deployment make seed # load rich fixtures (3 orgs, 6 mailboxes, a campaign) @@ -221,9 +221,11 @@ its own target with the full dev environment pre-wired (no manual env exports): ```bash make infra # once: the backing services in Docker make backend # API on :8080 (applies migrations on boot) +make forms # public forms service on :8090 (builds the forms/ app, then serves it) +make forms-web # Vite dev server for forms/ on :5175 when editing the app itself make consumer # event consumer (NATS -> postgres) make worker # send/sync worker (run more for parallelism) -make run # backend + consumer + worker in one terminal +make run # backend + forms + consumer + worker in one terminal make web # dashboard dev server on :5173 make admin # admin app on :5174 make site # marketing site on :4321 diff --git a/docs/content/docs/guides/forms.mdx b/docs/content/docs/guides/forms.mdx new file mode 100644 index 000000000..fbb073ff4 --- /dev/null +++ b/docs/content/docs/guides/forms.mdx @@ -0,0 +1,88 @@ +--- +title: "Forms" +description: "Build a hosted lead-capture form, style it to match your site, embed it anywhere, and turn every submission into a contact." +--- + +Forms turn website visitors into contacts. You build a form in the dashboard, publish it to a hosted page, and either share the link or embed the form on any website. Every submission is stored, and when it carries an email address it creates or updates a contact, files it under the categories you chose, and can drop it straight into a campaign. + +Open **Forms** in the sidebar. Viewing takes the same permission as viewing contacts; building and publishing take the manage-contacts permission. + +## Building a form + +**New form** creates a draft seeded with first name, last name and email, and opens the builder. The canvas in the middle is a live preview: what you see is exactly what the hosted page renders. + +- Add blocks by clicking them in the left palette or dragging them onto the canvas. +- Click a block to edit it in the right panel: label, placeholder, help text, required, and width. Two half-width fields share a row. +- Drag the grip on a selected block to reorder; the copy and trash buttons duplicate and remove it. + +### Field types + +| Block | Collects | +|---|---| +| Text, Email, Phone, Long text, Number, Date | A single value | +| Dropdown, Radio buttons | One of the options you define | +| Checkbox group | Any number of the options | +| Single checkbox | A yes, for consent lines like "I agree" | +| Hidden field | A constant you set, invisible to visitors. Useful for tagging which page or campaign a form sits on | +| Heading, Text block, Divider | Nothing. Layout only | + +### Where answers go + +Every answer is kept verbatim on the submission. On top of that, each input field can feed the contact: + +- **Contact columns.** A field mapped to first name, last name, email, company or phone fills that column. The email field always maps to email, and only one field can map to each column. +- **Custom fields.** An unmapped field stores its answer as a contact custom field named after the label, so a "Company size" question becomes a `Company size` custom field you can use in [segments](/guides/segments/) and email personalization. + +A form needs an email answer to create a contact. Forms without an email field still collect submissions; they just never touch the contact list. + +## Design + +The **Design** tab styles the form while the canvas updates live: font, form width, corner radius, field spacing, every color (page, card, inputs, labels, placeholder, accent), and the button's text, colors, size and width. The defaults are a clean, neutral card that looks right on most sites; changing nothing is a fine choice. + +## Settings + +The **Settings** tab controls what a submission does: + +- **Success message** is shown after submitting, or set a **redirect URL** to send the visitor to your own thank-you page instead. +- **Add to categories** files every submitted contact under the categories you pick, for example "Website leads". +- **Add to campaign** enrolls new contacts as leads in the campaign you pick. Sending still follows the campaign's own schedule, limits and windows; a form never causes immediate mail. +- **Spam protection** and **allowed embed domains** are covered below. + +## Publishing and sharing + +**Publish** puts the form online; **Unpublish** takes it offline again (the page starts answering not found). Archiving keeps the data but takes the page offline too. Edits to a published form go live when you save. + +The **Share** tab has four ways to use a published form: + +- **Hosted link.** The form's own page. Share it in an email signature or on social; no website needed. +- **JavaScript embed** (recommended). A script tag plus a `div` with a `data-warmbly-form` attribute. The form renders in place and sizes itself to its content, so there is no clipped bottom edge or dead space. +- **Popup.** The same script plus any element with a `data-warmbly-popup` attribute. Clicking it opens the form in an overlay, which closes on Escape, on the backdrop, and shortly after a successful submit. +- **Plain iframe.** For site builders that strip scripts. Fixed height, so set the `height` attribute to fit your form. + +These work in WordPress, Elementor, Webflow, Shopify, Framer and anywhere else custom HTML is allowed. The form itself always runs on the Warmbly origin, so the host page's CSS cannot break it and no visitor data flows through the host page. The script also fires a `warmbly:submitted` DOM event on the host page after each successful submit, if you want to run your own tracking. + +## Submissions + +The **Submissions** tab lists responses newest first, live: a new submission appears for everyone with the form open, without a refresh. Click a row for every answer; the trash button deletes a submission (the contact it created is kept). + +Each submitted contact carries `form` as its first-touch source, gets a "Submitted a form" entry on its activity timeline, and can be filtered in segments by source. The form's page views, submission count and conversion rate show on the Forms list. + +Submissions also fire the `form.submitted` [webhook](/guides/webhooks/), carrying the answers and the contact id, so you can forward leads to any external system. + +## Spam protection + +Every published form has layered protection with nothing to configure: + +- a honeypot field invisible to people but filled in by naive bots +- a minimum fill time, discarding submissions that arrive faster than a human can type +- per-address rate limits on the submit endpoint +- a size cap on every submission + +Two more controls are yours: + +- **Captcha challenge** adds a Cloudflare Turnstile check to the form. The toggle appears once the operator has configured Turnstile for the instance. +- **Allowed embed domains** limits which sites may embed the form. With domains listed, browsers refuse to frame the page anywhere else (a domain covers its subdomains); empty allows any site. The hosted link keeps working either way. + +## Data and portability + +Forms, their design and their submissions belong to the **Contacts** data group of a [workspace archive](/guides/workspace-export-import/), and the public form id travels with them, so embed codes installed on your website keep working after a move to another instance (point your embeds at the new host). A form's campaign link is dropped on import when campaigns stay behind. diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index 59dd1400b..662e64b12 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -16,6 +16,7 @@ "---Contacts and inbox---", "contacts-crm", "segments", + "forms", "website-tracking", "unibox", "meetings", diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index bbe2a32ff..db5488560 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -14,7 +14,7 @@ The data is split into groups. Every export includes **Workspace**; the rest are | Group | Contents | |-------|----------| | Workspace | The organization, members, roles, teams, mailboxes, API keys, webhooks, and settings, including the website tracking site key. Always included | -| Contacts | Contacts, categories, segments with their manual overrides, notes, activities, and the suppression list | +| Contacts | Contacts, categories, segments with their manual overrides, forms with their submissions, notes, activities, and the suppression list | | Campaigns | Campaigns, sequences, senders, attachments, and per-campaign settings | | CRM | Pipelines, deals, tasks, and meeting bookings | | Automations | Automations, connected integrations, and lead sync sources | diff --git a/forms/.gitignore b/forms/.gitignore new file mode 100644 index 000000000..5c7e89bd9 --- /dev/null +++ b/forms/.gitignore @@ -0,0 +1,15 @@ +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* + +node_modules +dist +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store diff --git a/forms/eslint.config.js b/forms/eslint.config.js new file mode 100644 index 000000000..4e95c04a8 --- /dev/null +++ b/forms/eslint.config.js @@ -0,0 +1,36 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +// Same shape as web/eslint.config.js, without that tree's legacy-code +// downgrades: this tree starts clean, so the strict defaults stay errors. +export default defineConfig([ + globalIgnores(['dist', 'node_modules', '**/._*']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/consistent-type-imports': 'error', + }, + }, +]) diff --git a/forms/index.html b/forms/index.html new file mode 100644 index 000000000..827c53b22 --- /dev/null +++ b/forms/index.html @@ -0,0 +1,13 @@ + + + + + + + Form + + +
+ + + diff --git a/forms/package.json b/forms/package.json new file mode 100644 index 000000000..07dc9023e --- /dev/null +++ b/forms/package.json @@ -0,0 +1,35 @@ +{ + "name": "warmbly-forms", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc -b", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-form": "^1.23.8", + "@tanstack/react-query": "^5.90.5", + "@tanstack/react-router": "^1.132.0", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/node": "^24.0.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.45.0", + "vite": "^7.3.6" + } +} diff --git a/forms/pnpm-lock.yaml b/forms/pnpm-lock.yaml new file mode 100644 index 000000000..440acebc8 --- /dev/null +++ b/forms/pnpm-lock.yaml @@ -0,0 +1,2232 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-form': + specifier: ^1.23.8 + version: 1.33.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-query': + specifier: ^5.90.5 + version: 5.102.8(react@19.2.8) + '@tanstack/react-router': + specifier: ^1.132.0 + version: 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.1.1 + version: 19.2.8 + react-dom: + specifier: ^19.1.1 + version: 19.2.8(react@19.2.8) + devDependencies: + '@eslint/js': + specifier: ^9.36.0 + version: 9.39.5 + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + '@types/react': + specifier: ^19.1.16 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.9 + version: 19.2.5(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.2.0(vite@7.3.6(@types/node@24.13.3)) + eslint: + specifier: ^9.36.0 + version: 9.39.5 + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.39.5) + eslint-plugin-react-refresh: + specifier: ^0.4.22 + version: 0.4.26(eslint@9.39.5) + globals: + specifier: ^16.4.0 + version: 16.5.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.45.0 + version: 8.68.0(eslint@9.39.5)(typescript@5.9.3) + vite: + specifier: ^7.3.6 + version: 7.3.6(@types/node@24.13.3) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@tanstack/devtools-event-client@0.4.4': + resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/form-core@1.33.5': + resolution: {integrity: sha512-3dfx9MBP0aq5sXKteikG629X9oviptrQj0IFRk9YGcb+lB7Kv5x8S17oOSk1wUWgjQZ4xVJEMbKwOAODymocgA==} + + '@tanstack/history@1.162.1': + resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + engines: {node: '>=20.19'} + + '@tanstack/pacer-lite@0.1.1': + resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} + engines: {node: '>=18'} + + '@tanstack/query-core@5.102.8': + resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} + + '@tanstack/react-form@1.33.5': + resolution: {integrity: sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA==} + peerDependencies: + '@tanstack/react-start': '*' + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + + '@tanstack/react-query@5.102.8': + resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.170.32': + resolution: {integrity: sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.11.1': + resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.27': + resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@typescript-eslint/eslint-plugin@8.68.0': + resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.68.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.68.0': + resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.68.0': + resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + 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 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.7: + resolution: {integrity: sha512-dML0wP6oak21rsNYCJpJB6O1BJIEwNpGrTw0URPfAk4hm0e3pRfCtzkfB6olBcXcVlU2rouCyz7lCyRB0OMVCA==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isbot@5.2.2: + resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + seroval-plugins@1.6.4: + resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.4: + resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==} + engines: {node: '>=10'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.68.0: + resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.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 + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + 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.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@tanstack/devtools-event-client@0.4.4': {} + + '@tanstack/form-core@1.33.5': + dependencies: + '@tanstack/devtools-event-client': 0.4.4 + '@tanstack/pacer-lite': 0.1.1 + '@tanstack/store': 0.11.1 + + '@tanstack/history@1.162.1': {} + + '@tanstack/pacer-lite@0.1.1': {} + + '@tanstack/query-core@5.102.8': {} + + '@tanstack/react-form@1.33.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/form-core': 1.33.5 + '@tanstack/react-store': 0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + transitivePeerDependencies: + - react-dom + + '@tanstack/react-query@5.102.8(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.102.8 + react: 19.2.8 + + '@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/history': 1.162.1 + '@tanstack/react-store': 0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.27 + isbot: 5.2.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/react-store@0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.11.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/react-store@0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/router-core@1.171.27': + dependencies: + '@tanstack/history': 1.162.1 + cookie-es: 3.1.1 + seroval: 1.6.4 + seroval-plugins: 1.6.4(seroval@1.6.4) + + '@tanstack/store@0.11.1': {} + + '@tanstack/store@0.9.3': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.5(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.68.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/type-utils': 8.68.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.68.0 + eslint: 9.39.5 + ignore: 7.0.7 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.68.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.68.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.9.3) + '@typescript-eslint/types': 8.68.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + + '@typescript-eslint/tsconfig-utils@8.68.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.68.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.68.0': {} + + '@typescript-eslint/typescript-estree@8.68.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.68.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.9.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.68.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.6(@types/node@24.13.3) + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.20: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001810: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + electron-to-chromium@1.5.416: {} + + esbuild@0.28.2: + 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 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.7: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isbot@5.2.2: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.54: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.18.0: {} + + react@19.2.8: {} + + resolve-from@4.0.0: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + seroval-plugins@1.6.4(seroval@1.6.4): + dependencies: + seroval: 1.6.4 + + seroval@1.6.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.68.0(eslint@9.39.5)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': 8.68.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.68.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + update-browserslist-db@1.3.2(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@7.3.6(@types/node@24.13.3): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} diff --git a/forms/pnpm-workspace.yaml b/forms/pnpm-workspace.yaml new file mode 100644 index 000000000..fc4a0361f --- /dev/null +++ b/forms/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# Trust the install-time build scripts of these packages so pnpm doesn't +# refuse to run them. esbuild is pulled in transitively by vite and needs +# to compile its native binary at install time. +allowBuilds: + esbuild: true diff --git a/forms/src/FormPage.tsx b/forms/src/FormPage.tsx new file mode 100644 index 000000000..c79a928fc --- /dev/null +++ b/forms/src/FormPage.tsx @@ -0,0 +1,53 @@ +// The /f/$publicId route: fetch the form, paint its theme, run the embed +// plumbing, render. The Go service already 404s unknown ids at the shell, so +// the in-app not-found path only fires when a form unpublishes mid-visit. + +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; + +import { fetchForm, FormNotFoundError } from "./api"; +import { applyDesign, resolveDesign } from "./design"; +import { isEmbedded, startResizeReporting } from "./embed"; +import { FormRenderer } from "./FormRenderer"; +import { NotFound, Unavailable } from "./NotFound"; + +export function FormPage() { + const { publicId } = useParams({ from: "/f/$publicId" }); + const query = useQuery({ + queryKey: ["form", publicId], + queryFn: () => fetchForm(publicId), + staleTime: Infinity, + retry: (count, err) => !(err instanceof FormNotFoundError) && count < 2, + }); + + const form = query.data; + const design = useMemo(() => (form ? resolveDesign(form.design) : null), [form]); + + useEffect(() => { + if (!form || !design) return; + document.title = form.name; + applyDesign(design); + if (isEmbedded()) document.body.classList.add("embed"); + return startResizeReporting(form.public_id); + }, [form, design]); + + if (query.isPending) return null; + if (query.isError) { + return query.error instanceof FormNotFoundError ? : ; + } + if (!form || !design) return null; + + return ( +
+
+ +
+ +
+ ); +} diff --git a/forms/src/FormRenderer.tsx b/forms/src/FormRenderer.tsx new file mode 100644 index 000000000..c73138718 --- /dev/null +++ b/forms/src/FormRenderer.tsx @@ -0,0 +1,201 @@ +// The live form: field state and validation via TanStack Form, submission to +// the same-origin /api. Client checks are a courtesy; the backend re-validates +// everything and its message wins the error box. + +import { useRef, useState } from "react"; +import { useForm } from "@tanstack/react-form"; + +import type { FormField, PublicForm } from "./api"; +import { submitForm, SubmitRejectedError } from "./api"; +import type { ResolvedDesign } from "./design"; +import { postSubmitted, redirect } from "./embed"; +import type { AnswerValue } from "./fields"; +import { FieldControl } from "./fields"; +import { Turnstile } from "./Turnstile"; +import { resetTurnstile } from "./turnstile"; + +type Answers = Record; + +const LAYOUT_TYPES = new Set(["heading", "paragraph", "divider", "hidden"]); + +function isInput(f: FormField): boolean { + return !LAYOUT_TYPES.has(f.type); +} + +function defaultsFor(fields: FormField[]): Answers { + const out: Answers = {}; + for (const f of fields) { + if (!isInput(f)) continue; + out[f.id] = f.type === "checkboxes" ? [] : f.type === "checkbox" ? false : ""; + } + return out; +} + +function validateField(f: FormField, v: AnswerValue): string | undefined { + const empty = + f.type === "checkboxes" ? !Array.isArray(v) || v.length === 0 : f.type === "checkbox" ? v !== true : typeof v !== "string" || v.trim() === ""; + if (f.required && empty) return `${f.label || "This field"} is required.`; + if (f.type === "email" && typeof v === "string" && v.trim() !== "" && !/^\S+@\S+\.\S+$/.test(v.trim())) { + return "Enter a valid email address."; + } + return undefined; +} + +function buildAnswers(fields: FormField[], value: Answers): Record { + const out: Record = {}; + for (const f of fields) { + if (f.type === "hidden") { + if (f.value) out[f.id] = [f.value]; + continue; + } + if (!isInput(f)) continue; + const v = value[f.id]; + if (f.type === "checkboxes") { + if (Array.isArray(v) && v.length > 0) out[f.id] = v; + } else if (f.type === "checkbox") { + if (v === true) out[f.id] = ["yes"]; + } else if (typeof v === "string" && v.trim() !== "") { + out[f.id] = [v]; + } + } + return out; +} + +function SuccessBlock({ message }: { message: string }) { + return ( +
+ + + + +

{message}

+
+ ); +} + +export function FormRenderer({ form: def, design }: { form: PublicForm; design: ResolvedDesign }) { + // Bots submit near-instantly after render; the backend discards those. + const renderedAt = useRef(Math.floor(Date.now() / 1000)); + const honeypot = useRef(null); + const [captchaToken, setCaptchaToken] = useState(""); + const [serverError, setServerError] = useState(""); + const [done, setDone] = useState(null); + + const form = useForm({ + defaultValues: defaultsFor(def.fields), + onSubmit: async ({ value }) => { + setServerError(""); + try { + const res = await submitForm(def.public_id, { + answers: buildAnswers(def.fields, value), + website: honeypot.current?.value ?? "", + _wt: renderedAt.current, + captcha_token: captchaToken || undefined, + source_url: document.referrer || window.location.href, + }); + postSubmitted(def.public_id); + if (res.redirect_url) { + redirect(res.redirect_url); + return; + } + setDone(res.message || "Thanks!"); + } catch (e) { + setServerError(e instanceof SubmitRejectedError ? e.message : "Something went wrong. Try again."); + resetTurnstile(); + setCaptchaToken(""); + } + }, + }); + + if (done !== null) return ; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + +
+ {serverError &&
{serverError}
} + {def.fields.map((f) => { + switch (f.type) { + case "heading": + return ( +
+

{f.label}

+
+ ); + case "paragraph": + return ( +
+

{f.value}

+
+ ); + case "divider": + return
; + case "hidden": + return null; + default: + return ( + validateField(f, value) }} + > + {(field) => ( +
+ {f.type !== "checkbox" && ( + + )} + field.handleChange(v)} + onBlur={field.handleBlur} + /> + {field.state.meta.errors.length > 0 && ( +

{String(field.state.meta.errors[0])}

+ )} + {f.help_text &&

{f.help_text}

} +
+ )} +
+ ); + } + })} + {def.captcha_site_key && } +
+ s.isSubmitting}> + {(isSubmitting) => ( + + )} + +
+
+
+ ); +} diff --git a/forms/src/NotFound.tsx b/forms/src/NotFound.tsx new file mode 100644 index 000000000..57020a425 --- /dev/null +++ b/forms/src/NotFound.tsx @@ -0,0 +1,20 @@ +// Terminal states, word-for-word with the static pages the Go service serves +// for the same conditions at the shell level. + +export function NotFound() { + return ( +
+

This form is no longer available

+

It may have been unpublished or removed.

+
+ ); +} + +export function Unavailable() { + return ( +
+

This form is temporarily unavailable

+

Please try again in a moment.

+
+ ); +} diff --git a/forms/src/Turnstile.tsx b/forms/src/Turnstile.tsx new file mode 100644 index 000000000..6e3ce5238 --- /dev/null +++ b/forms/src/Turnstile.tsx @@ -0,0 +1,47 @@ +// Cloudflare Turnstile widget, rendered explicitly so React owns the mount +// point. resetTurnstile() (turnstile.ts) clears the used token after a +// rejected submit. + +import { useEffect, useRef } from "react"; + +import { loadTurnstileScript } from "./turnstile"; + +export function Turnstile({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) { + const el = useRef(null); + const onTokenRef = useRef(onToken); + onTokenRef.current = onToken; + + useEffect(() => { + let widgetId: string | null = null; + let cancelled = false; + loadTurnstileScript() + .then(() => { + if (cancelled || !el.current || !window.turnstile) return; + widgetId = window.turnstile.render(el.current, { + sitekey: siteKey, + callback: (token: string) => onTokenRef.current(token), + "expired-callback": () => onTokenRef.current(""), + "error-callback": () => onTokenRef.current(""), + }); + }) + .catch(() => { + // widget stays absent; the backend rejects with a clear message + }); + return () => { + cancelled = true; + if (widgetId) { + try { + window.turnstile?.remove(widgetId); + } catch { + // already gone + } + } + }; + }, [siteKey]); + + return ( +
+
+
+ ); +} diff --git a/forms/src/api.ts b/forms/src/api.ts new file mode 100644 index 000000000..3c15ee271 --- /dev/null +++ b/forms/src/api.ts @@ -0,0 +1,111 @@ +// The same-origin JSON API the Go forms service (cmd/forms) exposes for this +// app. Mirrors internal/formserver: keep the shapes in sync with its +// public handlers. + +export type FormFieldType = + | "text" + | "email" + | "phone" + | "textarea" + | "number" + | "select" + | "radio" + | "checkboxes" + | "checkbox" + | "date" + | "hidden" + | "heading" + | "paragraph" + | "divider"; + +export interface FormField { + id: string; + type: FormFieldType; + label: string; + placeholder?: string; + help_text?: string; + required: boolean; + options?: string[]; + /** Hidden-field constant or paragraph body. */ + value?: string; + /** "full" (default) or "half"; two half fields share a row. */ + width?: string; + rows?: number; +} + +export interface FormDesign { + font_family?: string; + page_background?: string; + form_background?: string; + text_color?: string; + label_color?: string; + input_background?: string; + input_border_color?: string; + input_text_color?: string; + placeholder_color?: string; + accent_color?: string; + button_background?: string; + button_text_color?: string; + button_text?: string; + button_size?: string; + button_full_width?: boolean; + border_radius?: number; + max_width?: number; + spacing?: string; + shadow?: boolean; +} + +export interface PublicForm { + public_id: string; + name: string; + fields: FormField[]; + design: FormDesign; + captcha_site_key?: string; +} + +export interface SubmitPayload { + answers: Record; + /** Honeypot value; a human never fills it. */ + website: string; + /** Unix second the page was rendered; bots submit near-instantly. */ + _wt: number; + captcha_token?: string; + source_url?: string; +} + +export interface SubmitResult { + message: string; + redirect_url?: string; +} + +export class FormNotFoundError extends Error { + constructor() { + super("form not found"); + this.name = "FormNotFoundError"; + } +} + +export class SubmitRejectedError extends Error {} + +export async function fetchForm(publicId: string): Promise { + const res = await fetch(`/api/forms/${encodeURIComponent(publicId)}`, { + headers: { Accept: "application/json" }, + }); + if (res.status === 404) throw new FormNotFoundError(); + if (!res.ok) throw new Error(`form fetch failed (${res.status})`); + return (await res.json()) as PublicForm; +} + +export async function submitForm(publicId: string, payload: SubmitPayload): Promise { + const res = await fetch(`/api/forms/${encodeURIComponent(publicId)}/submit`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload), + }); + if (res.status === 404) throw new FormNotFoundError(); + const body = (await res.json().catch(() => null)) as { message?: string } | null; + if (!res.ok) { + throw new SubmitRejectedError(body?.message || "Something went wrong. Try again."); + } + return (body ?? { message: "Thanks!" }) as SubmitResult; +} diff --git a/forms/src/design.ts b/forms/src/design.ts new file mode 100644 index 000000000..d04563c4c --- /dev/null +++ b/forms/src/design.ts @@ -0,0 +1,105 @@ +// Mirrors resolveDesign in web/src/components/app/forms/FormPreview.tsx (the +// builder canvas): the hosted page must render exactly what the canvas shows. +// Defaults live here so stored designs stay sparse and old forms pick up new +// defaults. + +import type { FormDesign } from "./api"; + +export interface ResolvedDesign { + font: string; + fontStack: string; + pageBg: string; + formBg: string; + text: string; + label: string; + inputBg: string; + inputBorder: string; + inputText: string; + placeholder: string; + accent: string; + btnBg: string; + btnText: string; + btnLabel: string; + btnPad: string; + btnFont: number; + btnFullWidth: boolean; + radius: number; + inputRadius: number; + maxWidth: number; + gap: number; + shadow: boolean; +} + +export function resolveDesign(d: FormDesign): ResolvedDesign { + const fontStacks: Record = { + inter: "'Inter',system-ui,-apple-system,'Segoe UI',sans-serif", + serif: "Georgia,'Times New Roman',serif", + mono: "ui-monospace,SFMono-Regular,Menlo,monospace", + system: "system-ui,-apple-system,'Segoe UI',Roboto,sans-serif", + }; + const font = d.font_family && fontStacks[d.font_family] ? d.font_family : "system"; + const radius = d.border_radius ?? 10; + const btnSizes: Record = { + sm: ["8px 16px", 13], + md: ["10px 20px", 14], + lg: ["13px 26px", 16], + }; + const [btnPad, btnFont] = btnSizes[d.button_size ?? "md"] ?? btnSizes.md; + return { + font, + fontStack: fontStacks[font], + pageBg: d.page_background || "#f8fafc", + formBg: d.form_background || "#ffffff", + text: d.text_color || "#0f172a", + label: d.label_color || "#334155", + inputBg: d.input_background || "#ffffff", + inputBorder: d.input_border_color || "#e2e8f0", + inputText: d.input_text_color || "#0f172a", + placeholder: d.placeholder_color || "#94a3b8", + accent: d.accent_color || "#0284c7", + btnBg: d.button_background || "#0284c7", + btnText: d.button_text_color || "#ffffff", + btnLabel: d.button_text || "Submit", + btnPad, + btnFont, + btnFullWidth: !!d.button_full_width, + radius, + inputRadius: Math.min(radius, 12), + maxWidth: d.max_width ?? 560, + gap: d.spacing === "compact" ? 10 : d.spacing === "relaxed" ? 22 : 16, + shadow: d.shadow ?? true, + }; +} + +// applyDesign paints the resolved theme onto the document as CSS custom +// properties; styles.css reads nothing but these variables. +export function applyDesign(r: ResolvedDesign) { + const s = document.documentElement.style; + s.setProperty("--wf-font", r.fontStack); + s.setProperty("--wf-page-bg", r.pageBg); + s.setProperty("--wf-form-bg", r.formBg); + s.setProperty("--wf-text", r.text); + s.setProperty("--wf-label", r.label); + s.setProperty("--wf-input-bg", r.inputBg); + s.setProperty("--wf-input-border", r.inputBorder); + s.setProperty("--wf-input-text", r.inputText); + s.setProperty("--wf-placeholder", r.placeholder); + s.setProperty("--wf-accent", r.accent); + s.setProperty("--wf-btn-bg", r.btnBg); + s.setProperty("--wf-btn-text", r.btnText); + s.setProperty("--wf-btn-pad", r.btnPad); + s.setProperty("--wf-btn-font", `${r.btnFont}px`); + s.setProperty("--wf-radius", `${r.radius}px`); + s.setProperty("--wf-input-radius", `${r.inputRadius}px`); + s.setProperty("--wf-max-width", `${r.maxWidth}px`); + s.setProperty("--wf-gap", `${r.gap}px`); + s.setProperty("--wf-shadow", r.shadow ? "0 1px 2px rgba(15,23,42,.06),0 8px 24px rgba(15,23,42,.08)" : "none"); + + if (r.font === "inter" && !document.getElementById("wf-font")) { + const link = document.createElement("link"); + link.id = "wf-font"; + link.rel = "stylesheet"; + link.href = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"; + document.head.appendChild(link); + } +} diff --git a/forms/src/embed.ts b/forms/src/embed.ts new file mode 100644 index 000000000..398e6580d --- /dev/null +++ b/forms/src/embed.ts @@ -0,0 +1,49 @@ +// Embed plumbing: the hosted page runs inside an iframe the /forms.js loader +// mounts, and reports its height so the frame never clips. Message names are +// the contract with internal/formserver/static/forms-embed.js. + +export function isEmbedded(): boolean { + return new URLSearchParams(window.location.search).get("embed") === "1"; +} + +// startResizeReporting posts the document height to the parent on every size +// change. No-op outside a frame. Returns a cleanup function. +export function startResizeReporting(publicId: string): () => void { + if (window.parent === window) return () => {}; + const post = () => { + try { + window.parent.postMessage( + { type: "warmbly:resize", form: publicId, height: document.documentElement.scrollHeight }, + "*", + ); + } catch { + // sandboxed parents can refuse messages; the frame just keeps its height + } + }; + post(); + const ro = new ResizeObserver(post); + ro.observe(document.body); + return () => ro.disconnect(); +} + +export function postSubmitted(publicId: string) { + try { + window.parent.postMessage({ type: "warmbly:submitted", form: publicId }, "*"); + } catch { + // same as above + } +} + +// redirect escapes the iframe when possible so the thank-you page fills the +// tab, not a 480px frame. +export function redirect(url: string) { + try { + if (window.top) { + window.top.location.href = url; + return; + } + } catch { + // cross-origin top blocks assignment; fall through to the frame itself + } + window.location.href = url; +} diff --git a/forms/src/fields.tsx b/forms/src/fields.tsx new file mode 100644 index 000000000..474705ae1 --- /dev/null +++ b/forms/src/fields.tsx @@ -0,0 +1,140 @@ +// One controlled input per field type, class-for-class with the page +// stylesheet. Layout blocks (heading, paragraph, divider) render in +// FormRenderer; hidden fields never render at all. + +import type { FormField } from "./api"; + +export type AnswerValue = string | string[] | boolean; + +export function FieldControl({ + field, + value, + onChange, + onBlur, +}: { + field: FormField; + value: AnswerValue; + onChange: (v: AnswerValue) => void; + onBlur: () => void; +}) { + const id = `f-${field.id}`; + const str = typeof value === "string" ? value : ""; + + switch (field.type) { + case "textarea": + return ( +