← back to Govarbitrage
Snapshot live source before approved TK-11249 pricing release
d81ee96082db9f58491486e488f205da4bc1aaf3 · 2026-09-11 15:31:42 +0000 · Steve Abrams
Files touched
A .gitignoreA next.config.tsA package-lock.jsonA package.jsonA postcss.config.mjsA src/agents/appraiser.tsA src/agents/framework.tsA src/agents/run.tsA src/agents/scout.tsA src/agents/skeptic-rules.test.tsA src/agents/skeptic-rules.tsA src/agents/skeptic.tsA src/app/agent-ops/page.tsxA src/app/agents/page.tsxA src/app/api/agents/search/route.tsA src/app/api/auth/apple/route.tsA src/app/api/auth/login/route.tsA src/app/api/auth/logout/route.tsA src/app/api/auth/me/route.tsA src/app/api/billing/checkout/route.tsA src/app/api/billing/webhook/route.tsA src/app/api/import/apify-govdeals/route.tsA src/app/api/import/csv/route.tsA src/app/api/import/extension/route.tsA src/app/api/import/gsa/route.tsA src/app/api/import/url/route.tsA src/app/api/listings/[id]/buyer-lead/route.tsA src/app/api/listings/[id]/buyer-page/route.tsA src/app/api/listings/[id]/route.tsA src/app/api/listings/route.tsA src/app/api/newsletter/confirm/route.tsA src/app/api/newsletter/result-page.tsA src/app/api/newsletter/subscribe/route.tsA src/app/api/newsletter/unsubscribe/route.tsA src/app/b/[slug]/page.tsxA src/app/billing/success/page.tsxA src/app/deals/[date]/page.tsxA src/app/deals/page.tsxA src/app/globals.cssA src/app/layout.tsxA src/app/listings/[id]/page.tsxA src/app/login/page.tsxA src/app/newsletter/SubscribeForm.tsxA src/app/newsletter/page.tsxA src/app/page.tsxA src/app/pricing/UpgradeButton.tsxA src/app/pricing/page.tsxA src/app/privacy/page.tsxA src/app/reports/page.tsxA src/app/robots.tsA src/app/selling-avenues/page.tsxA src/app/sitemap.tsA src/components/agent-finder.tsxA src/components/dashboard-cards.tsxA src/components/listings-table.tsxA src/components/logout-button.tsxA src/components/offer-form.tsxA src/components/ui/badge.tsxA src/components/ui/button.tsxA src/components/ui/card.tsxA src/components/ui/input.tsxA src/engines/constants.tsA src/engines/costs.test.tsA src/engines/costs.tsA src/engines/demand.tsA src/engines/freight.tsA src/engines/scoring.test.tsA src/engines/scoring.tsA src/engines/types.tsA src/engines/valuation.test.tsA src/engines/valuation.tsA src/importers/apify-govdeals.tsA src/importers/csv.tsA src/importers/govdeals-free.tsA src/importers/govplanet-free.tsA src/importers/grays-free.tsA src/importers/gsa.tsA src/importers/ingest.tsA src/importers/municibid-free.tsA src/importers/publicsurplus-free.tsA src/importers/scrape.tsA src/lib/ai.tsA src/lib/auth.test.tsA src/lib/auth.tsA src/lib/billing.tsA src/lib/crypto.test.tsA src/lib/crypto.tsA src/lib/current-user.tsA src/lib/dashboard.tsA src/lib/db.tsA src/lib/digest-snapshot.tsA src/lib/digest-top.tsA src/lib/fleet-sso.tsA src/lib/hot-deals.tsA src/lib/listing-detail.test.tsA src/lib/listing-detail.tsA src/lib/listings.tsA src/lib/marketplace-links.test.tsA src/lib/marketplace-links.tsA src/lib/money-math-visibility.test.tsA src/lib/newsletter.tsA src/lib/password.tsA src/lib/places.test.tsA src/lib/places.tsA src/lib/rate-limit.test.tsA src/lib/rate-limit.tsA src/lib/reports.tsA src/lib/selling-avenues.tsA src/lib/send-digest.tsA src/lib/session.tsA src/lib/site.tsA src/lib/ssrf-guard.tsA src/lib/tiers.tsA src/lib/utils.tsA src/middleware.tsA src/pipeline/research.tsA src/worker/index.tsA tsconfig.json
Diff
commit d81ee96082db9f58491486e488f205da4bc1aaf3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 11 15:31:42 2026 +0000
Snapshot live source before approved TK-11249 pricing release
---
.gitignore | 21 +
next.config.ts | 18 +
package-lock.json | 9134 +++++++++++++++++++++++++
package.json | 70 +
postcss.config.mjs | 8 +
src/agents/appraiser.ts | 85 +
src/agents/framework.ts | 91 +
src/agents/run.ts | 80 +
src/agents/scout.ts | 49 +
src/agents/skeptic-rules.test.ts | 82 +
src/agents/skeptic-rules.ts | 69 +
src/agents/skeptic.ts | 93 +
src/app/agent-ops/page.tsx | 171 +
src/app/agents/page.tsx | 59 +
src/app/api/agents/search/route.ts | 68 +
src/app/api/auth/apple/route.ts | 104 +
src/app/api/auth/login/route.ts | 73 +
src/app/api/auth/logout/route.ts | 10 +
src/app/api/auth/me/route.ts | 10 +
src/app/api/billing/checkout/route.ts | 22 +
src/app/api/billing/webhook/route.ts | 48 +
src/app/api/import/apify-govdeals/route.ts | 28 +
src/app/api/import/csv/route.ts | 44 +
src/app/api/import/extension/route.ts | 35 +
src/app/api/import/gsa/route.ts | 26 +
src/app/api/import/url/route.ts | 30 +
src/app/api/listings/[id]/buyer-lead/route.ts | 37 +
src/app/api/listings/[id]/buyer-page/route.ts | 46 +
src/app/api/listings/[id]/route.ts | 11 +
src/app/api/listings/route.ts | 56 +
src/app/api/newsletter/confirm/route.ts | 30 +
src/app/api/newsletter/result-page.ts | 22 +
src/app/api/newsletter/subscribe/route.ts | 62 +
src/app/api/newsletter/unsubscribe/route.ts | 33 +
src/app/b/[slug]/page.tsx | 83 +
src/app/billing/success/page.tsx | 44 +
src/app/deals/[date]/page.tsx | 196 +
src/app/deals/page.tsx | 104 +
src/app/globals.css | 72 +
src/app/layout.tsx | 28 +
src/app/listings/[id]/page.tsx | 419 ++
src/app/login/page.tsx | 71 +
src/app/newsletter/SubscribeForm.tsx | 75 +
src/app/newsletter/page.tsx | 64 +
src/app/page.tsx | 55 +
src/app/pricing/UpgradeButton.tsx | 56 +
src/app/pricing/page.tsx | 118 +
src/app/privacy/page.tsx | 129 +
src/app/reports/page.tsx | 110 +
src/app/robots.ts | 25 +
src/app/selling-avenues/page.tsx | 138 +
src/app/sitemap.ts | 28 +
src/components/agent-finder.tsx | 349 +
src/components/dashboard-cards.tsx | 32 +
src/components/listings-table.tsx | 519 ++
src/components/logout-button.tsx | 18 +
src/components/offer-form.tsx | 57 +
src/components/ui/badge.tsx | 27 +
src/components/ui/button.tsx | 29 +
src/components/ui/card.tsx | 22 +
src/components/ui/input.tsx | 13 +
src/engines/constants.ts | 56 +
src/engines/costs.test.ts | 78 +
src/engines/costs.ts | 162 +
src/engines/demand.ts | 73 +
src/engines/freight.ts | 47 +
src/engines/scoring.test.ts | 72 +
src/engines/scoring.ts | 196 +
src/engines/types.ts | 133 +
src/engines/valuation.test.ts | 85 +
src/engines/valuation.ts | 84 +
src/importers/apify-govdeals.ts | 148 +
src/importers/csv.ts | 71 +
src/importers/govdeals-free.ts | 127 +
src/importers/govplanet-free.ts | 227 +
src/importers/grays-free.ts | 88 +
src/importers/gsa.ts | 91 +
src/importers/ingest.ts | 159 +
src/importers/municibid-free.ts | 246 +
src/importers/publicsurplus-free.ts | 211 +
src/importers/scrape.ts | 80 +
src/lib/ai.ts | 122 +
src/lib/auth.test.ts | 41 +
src/lib/auth.ts | 48 +
src/lib/billing.ts | 86 +
src/lib/crypto.test.ts | 26 +
src/lib/crypto.ts | 40 +
src/lib/current-user.ts | 53 +
src/lib/dashboard.ts | 41 +
src/lib/db.ts | 13 +
src/lib/digest-snapshot.ts | 134 +
src/lib/digest-top.ts | 32 +
src/lib/fleet-sso.ts | 44 +
src/lib/hot-deals.ts | 191 +
src/lib/listing-detail.test.ts | 85 +
src/lib/listing-detail.ts | 81 +
src/lib/listings.ts | 243 +
src/lib/marketplace-links.test.ts | 49 +
src/lib/marketplace-links.ts | 138 +
src/lib/money-math-visibility.test.ts | 27 +
src/lib/newsletter.ts | 141 +
src/lib/password.ts | 21 +
src/lib/places.test.ts | 538 ++
src/lib/places.ts | 634 ++
src/lib/rate-limit.test.ts | 32 +
src/lib/rate-limit.ts | 76 +
src/lib/reports.ts | 139 +
src/lib/selling-avenues.ts | 168 +
src/lib/send-digest.ts | 88 +
src/lib/session.ts | 42 +
src/lib/site.ts | 9 +
src/lib/ssrf-guard.ts | 104 +
src/lib/tiers.ts | 105 +
src/lib/utils.ts | 50 +
src/middleware.ts | 132 +
src/pipeline/research.ts | 257 +
src/worker/index.ts | 66 +
tsconfig.json | 44 +
118 files changed, 20280 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9a7a259
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,21 @@
+node_modules/
+.next/
+dist/
+build/
+out/
+.env
+.env.*
+!.env.example
+*.log
+.DS_Store
+coverage/
+playwright-report/
+test-results/
+prisma/*.db
+next-env.d.ts
+logs/
+
+# EAS ASC API key (secret) — belt+suspenders over child gitignore
+*.p8
+apps/*/credentials/
+credentials/
diff --git a/next.config.ts b/next.config.ts
new file mode 100644
index 0000000..00e5e73
--- /dev/null
+++ b/next.config.ts
@@ -0,0 +1,18 @@
+import type { NextConfig } from "next";
+import { fileURLToPath } from "node:url";
+
+const nextConfig: NextConfig = {
+ // Pin the workspace root (a stray parent lockfile confuses Turbopack's inference).
+ turbopack: { root: fileURLToPath(new URL(".", import.meta.url)) },
+ // Prisma 7 client is generated into node_modules; keep it external to the
+ // server bundle so the query engine binary resolves at runtime.
+ serverExternalPackages: ["@prisma/client", "bullmq", "ioredis"],
+ images: {
+ remotePatterns: [
+ { protocol: "https", hostname: "**" },
+ { protocol: "http", hostname: "**" },
+ ],
+ },
+};
+
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..c1fb172
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,9134 @@
+{
+ "name": "govarbitrage",
+ "version": "0.5.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "govarbitrage",
+ "version": "0.5.1",
+ "dependencies": {
+ "@prisma/client": "6.19.3",
+ "@tanstack/react-table": "8.21.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "csv-parse": "^5.6.0",
+ "jose": "^6.2.3",
+ "lucide-react": "^0.469.0",
+ "next": "16.2.10",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "stripe": "^22.3.1",
+ "tailwind-merge": "^2.6.0",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3.3.5",
+ "@playwright/test": "^1.49.1",
+ "@tailwindcss/postcss": "^4.0.0",
+ "@types/node": "^22.10.5",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^4.3.4",
+ "eslint": "^9.18.0",
+ "eslint-config-next": "16.2.10",
+ "postcss": "^8.4.49",
+ "prisma": "6.19.3",
+ "tailwindcss": "^4.0.0",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.3",
+ "vitest": "4.1.10"
+ },
+ "optionalDependencies": {
+ "bullmq": "5.80.0",
+ "ioredis": "^5.4.2"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@ioredis/commands": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
+ "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
+ "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
+ "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
+ "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
+ "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
+ "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
+ "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
+ "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
+ "license": "MIT"
+ },
+ "node_modules/@next/eslint-plugin-next": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz",
+ "integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-glob": "3.3.1"
+ }
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
+ "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
+ "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
+ "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
+ "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
+ "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
+ "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
+ "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
+ "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nolyfill/is-core-module": {
+ "version": "1.0.39",
+ "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
+ "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.4.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
+ "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "prisma": "*",
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/config": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
+ "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "c12": "3.1.0",
+ "deepmerge-ts": "7.1.5",
+ "effect": "3.21.0",
+ "empathic": "2.0.0"
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
+ "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
+ "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/fetch-engine": "6.19.3",
+ "@prisma/get-platform": "6.19.3"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
+ "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
+ "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/get-platform": "6.19.3"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
+ "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
+ "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "5.21.6",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
+ "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-x64": "4.3.2",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.2",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.2",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.2",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
+ "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
+ "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
+ "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
+ "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
+ "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
+ "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
+ "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
+ "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
+ "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
+ "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
+ "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
+ "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz",
+ "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.3.2",
+ "@tailwindcss/oxide": "4.3.2",
+ "postcss": "^8.5.15",
+ "tailwindcss": "4.3.2"
+ }
+ },
+ "node_modules/@tanstack/react-table": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
+ "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/table-core": "8.21.3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/@tanstack/table-core": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
+ "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json5": {
+ "version": "0.0.29",
+ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz",
+ "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.63.0",
+ "@typescript-eslint/type-utils": "8.63.0",
+ "@typescript-eslint/utils": "8.63.0",
+ "@typescript-eslint/visitor-keys": "8.63.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.63.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz",
+ "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.63.0",
+ "@typescript-eslint/types": "8.63.0",
+ "@typescript-eslint/typescript-estree": "8.63.0",
+ "@typescript-eslint/visitor-keys": "8.63.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz",
+ "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.63.0",
+ "@typescript-eslint/types": "^8.63.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz",
+ "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.63.0",
+ "@typescript-eslint/visitor-keys": "8.63.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz",
+ "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz",
+ "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.63.0",
+ "@typescript-eslint/typescript-estree": "8.63.0",
+ "@typescript-eslint/utils": "8.63.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz",
+ "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz",
+ "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.63.0",
+ "@typescript-eslint/tsconfig-utils": "8.63.0",
+ "@typescript-eslint/types": "8.63.0",
+ "@typescript-eslint/visitor-keys": "8.63.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz",
+ "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.63.0",
+ "@typescript-eslint/types": "8.63.0",
+ "@typescript-eslint/typescript-estree": "8.63.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz",
+ "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.63.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-android-arm-eabi": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
+ "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-android-arm64": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz",
+ "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-arm64": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz",
+ "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-x64": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz",
+ "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-freebsd-x64": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz",
+ "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz",
+ "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz",
+ "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz",
+ "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz",
+ "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-loong64-gnu": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz",
+ "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-loong64-musl": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz",
+ "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz",
+ "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz",
+ "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz",
+ "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz",
+ "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz",
+ "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-musl": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz",
+ "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-openharmony-arm64": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
+ "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
+ "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
+ "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
+ "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
+ "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+ "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlast": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
+ "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlastindex": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
+ "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-shim-unscopables": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+ "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+ "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.tosorted": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
+ "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3",
+ "es-errors": "^1.3.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-types-flow": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
+ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/axe-core": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
+ "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.42",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
+ "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
+ "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.5",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
+ "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.42",
+ "caniuse-lite": "^1.0.30001800",
+ "electron-to-chromium": "^1.5.387",
+ "node-releases": "^2.0.50",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/bullmq": {
+ "version": "5.80.0",
+ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.80.0.tgz",
+ "integrity": "sha512-xrCjINZcW6P4irrGZrOXPizzBbvgaP01u2oXGmqY1O5fll56z7OOf9vdnTzK7X4P34vo6ayMPQjyhJN+KwAMWA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "cron-parser": "4.9.0",
+ "ioredis": "5.10.1",
+ "msgpackr": "2.0.4",
+ "node-abort-controller": "3.1.1",
+ "semver": "7.8.5",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "peerDependencies": {
+ "redis": ">=5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "redis": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bullmq/node_modules/@ioredis/commands": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
+ "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/bullmq/node_modules/ioredis": {
+ "version": "5.10.1",
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
+ "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@ioredis/commands": "1.5.1",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ioredis"
+ }
+ },
+ "node_modules/bullmq/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/c12": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
+ "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "confbox": "^0.2.2",
+ "defu": "^6.1.4",
+ "dotenv": "^16.6.1",
+ "exsolve": "^1.0.7",
+ "giget": "^2.0.0",
+ "jiti": "^2.4.2",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "perfect-debounce": "^1.0.0",
+ "pkg-types": "^2.2.0",
+ "rc9": "^2.1.2"
+ },
+ "peerDependencies": {
+ "magicast": "^0.3.5"
+ },
+ "peerDependenciesMeta": {
+ "magicast": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001803",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz",
+ "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/citty": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
+ "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "consola": "^3.2.3"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
+ "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cron-parser": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
+ "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "luxon": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csv-parse": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
+ "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
+ "license": "MIT"
+ },
+ "node_modules/damerau-levenshtein": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
+ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge-ts": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
+ "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
+ "devOptional": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "devOptional": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/effect": {
+ "version": "3.21.0",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
+ "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "fast-check": "^3.23.1"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.389",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
+ "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/empathic": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
+ "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.21.6",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
+ "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.2",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
+ "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-abstract-get": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz",
+ "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.2",
+ "is-callable": "^1.2.7",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-iterator-helpers": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz",
+ "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.2",
+ "es-errors": "^1.3.0",
+ "es-set-tostringtag": "^2.1.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.3.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "iterator.prototype": "^1.1.5",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
+ "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+ "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz",
+ "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-abstract-get": "^1.0.0",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.1.0",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "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.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-next": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz",
+ "integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@next/eslint-plugin-next": "16.2.10",
+ "eslint-import-resolver-node": "^0.3.6",
+ "eslint-import-resolver-typescript": "^3.5.2",
+ "eslint-plugin-import": "^2.32.0",
+ "eslint-plugin-jsx-a11y": "^6.10.0",
+ "eslint-plugin-react": "^7.37.0",
+ "eslint-plugin-react-hooks": "^7.0.0",
+ "globals": "16.4.0",
+ "typescript-eslint": "^8.46.0"
+ },
+ "peerDependencies": {
+ "eslint": ">=9.0.0",
+ "typescript": ">=3.3.1"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-next/node_modules/globals": {
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
+ "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint-import-resolver-node": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
+ "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7",
+ "is-core-module": "^2.16.1",
+ "resolve": "^2.0.0-next.6"
+ }
+ },
+ "node_modules/eslint-import-resolver-node/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-import-resolver-typescript": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz",
+ "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@nolyfill/is-core-module": "1.0.39",
+ "debug": "^4.4.0",
+ "get-tsconfig": "^4.10.0",
+ "is-bun-module": "^2.0.0",
+ "stable-hash": "^0.0.5",
+ "tinyglobby": "^0.2.13",
+ "unrs-resolver": "^1.6.2"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-import-resolver-typescript"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "eslint-plugin-import": "*",
+ "eslint-plugin-import-x": "*"
+ },
+ "peerDependenciesMeta": {
+ "eslint-plugin-import": {
+ "optional": true
+ },
+ "eslint-plugin-import-x": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz",
+ "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import": {
+ "version": "2.32.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
+ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rtsao/scc": "^1.1.0",
+ "array-includes": "^3.1.9",
+ "array.prototype.findlastindex": "^1.2.6",
+ "array.prototype.flat": "^1.3.3",
+ "array.prototype.flatmap": "^1.3.3",
+ "debug": "^3.2.7",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.9",
+ "eslint-module-utils": "^2.12.1",
+ "hasown": "^2.0.2",
+ "is-core-module": "^2.16.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "object.groupby": "^1.0.3",
+ "object.values": "^1.2.1",
+ "semver": "^6.3.1",
+ "string.prototype.trimend": "^1.0.9",
+ "tsconfig-paths": "^3.15.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-jsx-a11y": {
+ "version": "6.10.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
+ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "aria-query": "^5.3.2",
+ "array-includes": "^3.1.8",
+ "array.prototype.flatmap": "^1.3.2",
+ "ast-types-flow": "^0.0.8",
+ "axe-core": "^4.10.0",
+ "axobject-query": "^4.1.0",
+ "damerau-levenshtein": "^1.0.8",
+ "emoji-regex": "^9.2.2",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^3.3.5",
+ "language-tags": "^1.0.9",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "safe-regex-test": "^1.0.3",
+ "string.prototype.includes": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-react": {
+ "version": "7.37.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
+ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.8",
+ "array.prototype.findlast": "^1.2.5",
+ "array.prototype.flatmap": "^1.3.3",
+ "array.prototype.tosorted": "^1.1.4",
+ "doctrine": "^2.1.0",
+ "es-iterator-helpers": "^1.2.1",
+ "estraverse": "^5.3.0",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.9",
+ "object.fromentries": "^2.0.8",
+ "object.values": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.5",
+ "semver": "^6.3.1",
+ "string.prototype.matchall": "^4.0.12",
+ "string.prototype.repeat": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/exsolve": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
+ "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-check": {
+ "version": "3.23.2",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+ "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+ "devOptional": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
+ "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz",
+ "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2",
+ "hasown": "^2.0.4",
+ "is-callable": "^1.2.7",
+ "is-document.all": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.14.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
+ "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/giget": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
+ "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.0",
+ "defu": "^6.1.4",
+ "node-fetch-native": "^1.6.6",
+ "nypm": "^0.6.0",
+ "pathe": "^2.0.3"
+ },
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ioredis": {
+ "version": "5.11.1",
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
+ "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@ioredis/commands": "1.10.0",
+ "cluster-key-slot": "1.1.1",
+ "debug": "4.4.3",
+ "denque": "2.1.0",
+ "redis-errors": "1.2.0",
+ "redis-parser": "3.0.0",
+ "standard-as-callback": "2.1.0"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ioredis"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bun-module": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
+ "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.7.1"
+ }
+ },
+ "node_modules/is-bun-module/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-document.all": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
+ "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
+ "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/jose": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
+ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/language-subtag-registry": {
+ "version": "0.3.23",
+ "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
+ "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/language-tags": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz",
+ "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "language-subtag-registry": "^0.3.20"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.469.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz",
+ "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/msgpackr": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz",
+ "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==",
+ "license": "MIT",
+ "optional": true,
+ "optionalDependencies": {
+ "msgpackr-extract": "^3.0.4"
+ }
+ },
+ "node_modules/msgpackr-extract": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
+ "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.2.2"
+ },
+ "bin": {
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-postinstall": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
+ "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "napi-postinstall": "lib/cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/napi-postinstall"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/next": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
+ "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.2.10",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.9.19",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.2.10",
+ "@next/swc-darwin-x64": "16.2.10",
+ "@next/swc-linux-arm64-gnu": "16.2.10",
+ "@next/swc-linux-arm64-musl": "16.2.10",
+ "@next/swc-linux-x64-gnu": "16.2.10",
+ "@next/swc-linux-x64-musl": "16.2.10",
+ "@next/swc-win32-arm64-msvc": "16.2.10",
+ "@next/swc-win32-x64-msvc": "16.2.10",
+ "sharp": "^0.34.5"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-abort-controller": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
+ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-exports-info": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
+ "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array.prototype.flatmap": "^1.3.3",
+ "es-errors": "^1.3.0",
+ "object.entries": "^1.1.9",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/node-gyp-build-optional-packages": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nypm": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
+ "integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.2.2",
+ "pathe": "^2.0.3",
+ "tinyexec": "^1.2.4"
+ },
+ "bin": {
+ "nypm": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nypm/node_modules/citty": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
+ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
+ "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+ "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.groupby": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
+ "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+ "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+ "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
+ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
+ "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.4",
+ "exsolve": "^1.0.8",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prisma": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
+ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/config": "6.19.3",
+ "@prisma/engines": "6.19.3"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "devOptional": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/rc9": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
+ "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "destr": "^2.0.3"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "2.0.0-next.7",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
+ "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.2",
+ "node-exports-info": "^1.6.0",
+ "object-keys": "^1.1.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
+ "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "get-intrinsic": "^1.3.0",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stable-hash": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
+ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.includes": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
+ "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+ "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.repeat": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
+ "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz",
+ "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.2",
+ "es-object-atoms": "^1.1.2",
+ "has-property-descriptors": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz",
+ "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stripe": {
+ "version": "22.3.1",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.1.tgz",
+ "integrity": "sha512-6XSLN0KK0IdfXqDHqMg6CDoO3eO62ye9dhzw0fZp55AUOzHR1YRJOqYHTsuCi4QJ4Ni/usEmXtq69c9ghKDmYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
+ "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
+ "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tsconfig-paths": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
+ "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/json5": "^0.0.29",
+ "json5": "^1.0.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
+ "node_modules/tsconfig-paths/node_modules/json5": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+ "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/tsx": {
+ "version": "4.23.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
+ "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tsx/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz",
+ "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "for-each": "^0.3.5",
+ "gopd": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "possible-typed-array-names": "^1.1.0",
+ "reflect.getprototypeof": "^1.0.10"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.63.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz",
+ "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.63.0",
+ "@typescript-eslint/parser": "8.63.0",
+ "@typescript-eslint/typescript-estree": "8.63.0",
+ "@typescript-eslint/utils": "8.63.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/unrs-resolver": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
+ "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "napi-postinstall": "^0.3.4"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unrs-resolver"
+ },
+ "optionalDependencies": {
+ "@unrs/resolver-binding-android-arm-eabi": "1.12.2",
+ "@unrs/resolver-binding-android-arm64": "1.12.2",
+ "@unrs/resolver-binding-darwin-arm64": "1.12.2",
+ "@unrs/resolver-binding-darwin-x64": "1.12.2",
+ "@unrs/resolver-binding-freebsd-x64": "1.12.2",
+ "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2",
+ "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2",
+ "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2",
+ "@unrs/resolver-binding-linux-arm64-musl": "1.12.2",
+ "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2",
+ "@unrs/resolver-binding-linux-loong64-musl": "1.12.2",
+ "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2",
+ "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2",
+ "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2",
+ "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2",
+ "@unrs/resolver-binding-linux-x64-gnu": "1.12.2",
+ "@unrs/resolver-binding-linux-x64-musl": "1.12.2",
+ "@unrs/resolver-binding-openharmony-arm64": "1.12.2",
+ "@unrs/resolver-binding-wasm32-wasi": "1.12.2",
+ "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2",
+ "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2",
+ "@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "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
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.22",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
+ "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d80850f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "govarbitrage",
+ "version": "0.5.1",
+ "private": true,
+ "description": "Discover, research, value, and score government-surplus auction listings for resale arbitrage.",
+ "type": "module",
+ "prisma": {
+ "seed": "tsx prisma/seed.ts"
+ },
+ "scripts": {
+ "dev": "next dev",
+ "build": "prisma generate && next build",
+ "start": "next start",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "db:generate": "prisma generate",
+ "db:push": "prisma db push",
+ "db:migrate": "prisma migrate dev",
+ "db:seed": "tsx prisma/seed.ts",
+ "worker": "tsx src/worker/index.ts",
+ "agents": "tsx src/agents/run.ts",
+ "e2e": "playwright test"
+ },
+ "dependencies": {
+ "@prisma/client": "6.19.3",
+ "@tanstack/react-table": "8.21.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "csv-parse": "^5.6.0",
+ "jose": "^6.2.3",
+ "lucide-react": "^0.469.0",
+ "next": "16.2.10",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "stripe": "^22.3.1",
+ "tailwind-merge": "^2.6.0",
+ "zod": "^3.24.1"
+ },
+ "optionalDependencies": {
+ "bullmq": "5.80.0",
+ "ioredis": "^5.4.2"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3.3.5",
+ "@playwright/test": "^1.49.1",
+ "@tailwindcss/postcss": "^4.0.0",
+ "@types/node": "^22.10.5",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^4.3.4",
+ "eslint": "^9.18.0",
+ "eslint-config-next": "16.2.10",
+ "postcss": "^8.4.49",
+ "prisma": "6.19.3",
+ "tailwindcss": "^4.0.0",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.3",
+ "vitest": "4.1.10"
+ },
+ "allowScripts": {
+ "@prisma/engines@6.19.3": true,
+ "prisma@6.19.3": true,
+ "esbuild@0.28.1": true,
+ "sharp@0.34.5": true,
+ "unrs-resolver@1.12.2": true,
+ "msgpackr-extract@3.0.4": true
+ }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..79bcf13
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/src/agents/appraiser.ts b/src/agents/appraiser.ts
new file mode 100644
index 0000000..3a0544e
--- /dev/null
+++ b/src/agents/appraiser.ts
@@ -0,0 +1,85 @@
+import { prisma } from "@/lib/db";
+import { runAgent, type AgentRunResult } from "./framework";
+import { runResearch } from "@/pipeline/research";
+
+// The Appraiser re-researches poorly-identified listings WITH the local AI
+// (Ollama qwen3:14b) so valuations become item-specific instead of the
+// keyword-heuristic placeholder (the "$900 retail / 214% ROI for everything"
+// artifact). Sequential (concurrency 1) — one local LLM, ~30-60s per listing.
+
+const num = (d: unknown): number | null => (d == null ? null : Number(d));
+
+/**
+ * Pick up to `limit` ACTIVE, still-open listings ordered by soonest close
+ * where identification is weak (manufacturer/model missing, or the last
+ * research pass was heuristic-only / low confidence), and re-run research
+ * with useAI + writeIdentity.
+ */
+export async function runAppraiser(limit = 25): Promise<AgentRunResult> {
+ return runAgent("appraiser", async (ctx) => {
+ const now = new Date();
+ const candidates = await prisma.listing.findMany({
+ where: {
+ listingStatus: "ACTIVE",
+ closingAt: { gt: now },
+ OR: [
+ { manufacturer: null },
+ { model: null },
+ { identifiedBy: null },
+ { identifiedBy: "heuristic" },
+ { research: { is: { confidenceScore: { lt: 40 } } } },
+ ],
+ },
+ orderBy: { closingAt: "asc" },
+ take: limit,
+ include: { research: true },
+ });
+
+ let aiIdentified = 0;
+ let fallbacks = 0;
+
+ for (const l of candidates) {
+ const beforeRetail = num(l.research?.newRetail);
+ const beforeConfidence = num(l.research?.confidenceScore);
+
+ const result = await runResearch(l.id, { useAI: true, writeIdentity: true });
+ ctx.count();
+
+ const after = await prisma.research.findUnique({ where: { listingId: l.id } });
+ const afterRetail = num(after?.newRetail);
+ const afterConfidence = num(after?.confidenceScore);
+
+ const fmt = (v: number | null, pct = false) =>
+ v == null ? "—" : pct ? `${Math.round(v)}/100` : `$${Math.round(v)}`;
+ const delta =
+ `newRetail ${fmt(beforeRetail)} → ${fmt(afterRetail)}, ` +
+ `confidence ${fmt(beforeConfidence, true)} → ${fmt(afterConfidence, true)}`;
+
+ if (result.identifiedBy === "heuristic") {
+ fallbacks++;
+ await ctx.finding({
+ kind: "AI_FALLBACK",
+ severity: "WARN",
+ listingId: l.id,
+ listingTitle: l.title,
+ title: `Ollama returned nothing — heuristic fallback kept for "${l.title.slice(0, 80)}"`,
+ detail: `${delta}. Valuation remains keyword-heuristic; re-run when the local model is reachable.`,
+ });
+ } else {
+ aiIdentified++;
+ await ctx.finding({
+ kind: "REAPPRAISED",
+ severity: "INFO",
+ listingId: l.id,
+ listingTitle: l.title,
+ title: `Re-appraised via ${result.identifiedBy}: ${delta}`,
+ detail:
+ `${l.title}. Net $${Math.round(result.expectedNetProfit)}, ` +
+ `ROI ${Math.round(result.roi * 100)}%, overall score ${Math.round(result.overallScore)}.`,
+ });
+ }
+ }
+
+ return `${candidates.length} listings appraised — ${aiIdentified} AI-identified, ${fallbacks} heuristic fallbacks`;
+ });
+}
diff --git a/src/agents/framework.ts b/src/agents/framework.ts
new file mode 100644
index 0000000..cd6884f
--- /dev/null
+++ b/src/agents/framework.ts
@@ -0,0 +1,91 @@
+import { prisma } from "@/lib/db";
+
+// Minimal, dependency-free agent runner. Every agent execution is recorded as
+// an AgentRun; observations stream in as AgentFindings via ctx.finding().
+// Errors are caught → status FAILED with the message in summary, so a crashed
+// agent still leaves an auditable row instead of vanishing.
+
+export type Severity = "INFO" | "WARN" | "CRITICAL";
+
+export interface FindingInput {
+ kind: string;
+ severity: Severity;
+ title: string;
+ detail?: string;
+ listingId?: string;
+ listingTitle?: string;
+}
+
+export interface AgentContext {
+ runId: string;
+ agent: string;
+ /** Persist one finding against this run. */
+ finding(f: FindingInput): Promise<void>;
+ /** Increment the run's itemsProcessed counter (default +1). */
+ count(n?: number): void;
+}
+
+export interface AgentRunResult {
+ runId: string;
+ agent: string;
+ status: "OK" | "FAILED";
+ itemsProcessed: number;
+ summary: string | null;
+ findings: number;
+}
+
+/**
+ * Execute `fn` inside a tracked AgentRun. The function may return a summary
+ * string; on throw the run is marked FAILED with the error message.
+ */
+export async function runAgent(
+ name: string,
+ fn: (ctx: AgentContext) => Promise<string | void>,
+): Promise<AgentRunResult> {
+ const run = await prisma.agentRun.create({
+ data: { agent: name, status: "RUNNING" },
+ });
+
+ let items = 0;
+ let findings = 0;
+
+ const ctx: AgentContext = {
+ runId: run.id,
+ agent: name,
+ async finding(f) {
+ findings++;
+ await prisma.agentFinding.create({
+ data: {
+ runId: run.id,
+ agent: name,
+ kind: f.kind,
+ severity: f.severity,
+ title: f.title,
+ detail: f.detail,
+ listingId: f.listingId,
+ listingTitle: f.listingTitle,
+ },
+ });
+ },
+ count(n = 1) {
+ items += n;
+ },
+ };
+
+ try {
+ const summary = (await fn(ctx)) ?? null;
+ await prisma.agentRun.update({
+ where: { id: run.id },
+ data: { status: "OK", finishedAt: new Date(), itemsProcessed: items, summary },
+ });
+ return { runId: run.id, agent: name, status: "OK", itemsProcessed: items, summary, findings };
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ const summary = `FAILED: ${message}`.slice(0, 1000);
+ await prisma.agentRun.update({
+ where: { id: run.id },
+ data: { status: "FAILED", finishedAt: new Date(), itemsProcessed: items, summary },
+ });
+ return { runId: run.id, agent: name, status: "FAILED", itemsProcessed: items, summary, findings };
+ }
+}
diff --git a/src/agents/run.ts b/src/agents/run.ts
new file mode 100644
index 0000000..9ebca32
--- /dev/null
+++ b/src/agents/run.ts
@@ -0,0 +1,80 @@
+// CLI runner for the agent layer.
+// npm run agents -- appraiser [N] re-appraise up to N listings via local AI (default 25)
+// npm run agents -- skeptic [N] adversarial audit of the top N ranked (default 30)
+// npm run agents -- scout hot deals closing within 48h
+// npm run agents -- all skeptic → scout → appraiser (small batch)
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import type { AgentRunResult } from "./framework";
+
+// Minimal .env loader (same pattern as scripts/send-digest.ts — don't rely on
+// Prisma's dotenv side-effect when invoked via tsx).
+function loadEnv() {
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+ try {
+ const raw = readFileSync(join(root, ".env"), "utf8");
+ for (const line of raw.split("\n")) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+ if (!m) continue;
+ const key = m[1];
+ let val = m[2].trim();
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+ val = val.slice(1, -1);
+ }
+ if (process.env[key] === undefined) process.env[key] = val;
+ }
+ } catch {
+ /* no .env — rely on ambient env */
+ }
+}
+loadEnv();
+
+const { prisma } = await import("../lib/db");
+const { runAppraiser } = await import("./appraiser");
+const { runSkeptic } = await import("./skeptic");
+const { runScout } = await import("./scout");
+
+function report(r: AgentRunResult) {
+ console.log(
+ `[${r.agent}] ${r.status} — run ${r.runId} · ${r.itemsProcessed} items · ${r.findings} findings\n ${r.summary ?? ""}`,
+ );
+}
+
+async function main() {
+ const [agent, nArg] = process.argv.slice(2);
+ const n = nArg != null ? Number(nArg) : undefined;
+ if (nArg != null && (!Number.isFinite(n) || n! <= 0)) {
+ throw new Error(`Invalid count "${nArg}" — expected a positive number.`);
+ }
+
+ switch ((agent || "").toLowerCase()) {
+ case "appraiser":
+ report(await runAppraiser(n ?? 25));
+ break;
+ case "skeptic":
+ report(await runSkeptic(n ?? 30));
+ break;
+ case "scout":
+ report(await runScout());
+ break;
+ case "all":
+ report(await runSkeptic(n ?? 30));
+ report(await runScout());
+ report(await runAppraiser(n ?? 25));
+ break;
+ default:
+ console.log("Usage: npm run agents -- <appraiser [N] | skeptic [N] | scout | all>");
+ process.exitCode = 2;
+ }
+}
+
+main()
+ .catch((e) => {
+ console.error("[agents] ERROR:", e instanceof Error ? e.message : e);
+ process.exitCode = 1;
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/src/agents/scout.ts b/src/agents/scout.ts
new file mode 100644
index 0000000..5ddd45f
--- /dev/null
+++ b/src/agents/scout.ts
@@ -0,0 +1,49 @@
+import { prisma } from "@/lib/db";
+import { runAgent, type AgentRunResult } from "./framework";
+
+// The Scout watches the clock: open listings closing within 48h that carry a
+// strong Overall Opportunity score (>= minScore). Each hit is an INFO finding;
+// anything closing within 6h escalates to CRITICAL.
+
+const HOUR = 3_600_000;
+
+export async function runScout(minScore = 75): Promise<AgentRunResult> {
+ return runAgent("scout", async (ctx) => {
+ const now = Date.now();
+ const listings = await prisma.listing.findMany({
+ where: {
+ listingStatus: "ACTIVE",
+ closingAt: { gt: new Date(now), lte: new Date(now + 48 * HOUR) },
+ scores: { some: { profile: "OVERALL_OPPORTUNITY", value: { gte: minScore } } },
+ },
+ include: {
+ scores: { where: { profile: "OVERALL_OPPORTUNITY" } },
+ costBreakdown: true,
+ },
+ orderBy: { closingAt: "asc" },
+ });
+
+ let critical = 0;
+ for (const l of listings) {
+ ctx.count();
+ const score = l.scores[0]?.value ?? 0;
+ const hoursLeft = (new Date(l.closingAt!).getTime() - now) / HOUR;
+ const isCritical = hoursLeft <= 6;
+ if (isCritical) critical++;
+ const net = l.costBreakdown ? Math.round(Number(l.costBreakdown.expectedNetProfit)) : null;
+ await ctx.finding({
+ kind: "HOT_CLOSING",
+ severity: isCritical ? "CRITICAL" : "INFO",
+ listingId: l.id,
+ listingTitle: l.title,
+ title: `Closing in ${hoursLeft < 1 ? "<1h" : Math.round(hoursLeft) + "h"} @ score ${Math.round(score)}: "${l.title.slice(0, 70)}"`,
+ detail:
+ `Overall ${Math.round(score)}/100, closes ${l.closingAt?.toISOString()}` +
+ (net != null ? `, est. net $${net}` : "") +
+ `. ${isCritical ? "Under 6 hours — act now or drop it." : "Within 48h window."}`,
+ });
+ }
+
+ return `${listings.length} hot deals closing within 48h (${critical} within 6h) at score >= ${minScore}`;
+ });
+}
diff --git a/src/agents/skeptic-rules.test.ts b/src/agents/skeptic-rules.test.ts
new file mode 100644
index 0000000..5777766
--- /dev/null
+++ b/src/agents/skeptic-rules.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import { findCloneGroups, isClosingStale, isLowConfidence, type ValuationTuple } from "./skeptic-rules";
+
+const t = (
+ listingId: string,
+ newRetail: number | null,
+ expectedNetProfit: number | null,
+ roi: number | null,
+): ValuationTuple => ({ listingId, title: listingId, newRetail, expectedNetProfit, roi });
+
+describe("findCloneGroups", () => {
+ it("flags >=3 listings sharing the exact (newRetail, net, roi) tuple", () => {
+ const items = [
+ t("a", 900, 189, 2.14),
+ t("b", 900, 189, 2.14),
+ t("c", 900, 189, 2.14),
+ t("d", 450, 80, 0.6),
+ ];
+ const groups = findCloneGroups(items);
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members.map((m) => m.listingId)).toEqual(["a", "b", "c"]);
+ expect(groups[0].key).toBe("$900 retail / $189 net / 214% ROI");
+ });
+
+ it("does not flag pairs (below minGroup)", () => {
+ const items = [t("a", 900, 189, 2.14), t("b", 900, 189, 2.14), t("c", 450, 80, 0.6)];
+ expect(findCloneGroups(items)).toHaveLength(0);
+ });
+
+ it("treats a small dollar difference as distinct", () => {
+ const items = [t("a", 900, 189, 2.14), t("b", 901, 189, 2.14), t("c", 900, 189, 2.14)];
+ expect(findCloneGroups(items)).toHaveLength(0);
+ });
+
+ it("ignores listings with missing values", () => {
+ const items = [t("a", 900, 189, 2.14), t("b", 900, 189, 2.14), t("c", null, 189, 2.14)];
+ expect(findCloneGroups(items)).toHaveLength(0);
+ });
+
+ it("returns multiple groups, largest first", () => {
+ const items = [
+ t("a", 900, 189, 2.14),
+ t("b", 900, 189, 2.14),
+ t("c", 900, 189, 2.14),
+ t("d", 600, 120, 1.5),
+ t("e", 600, 120, 1.5),
+ t("f", 600, 120, 1.5),
+ t("g", 600, 120, 1.5),
+ ];
+ const groups = findCloneGroups(items);
+ expect(groups).toHaveLength(2);
+ expect(groups[0].members).toHaveLength(4);
+ expect(groups[1].members).toHaveLength(3);
+ });
+});
+
+describe("isLowConfidence", () => {
+ it("handles the 0..100 scale (Research.confidenceScore)", () => {
+ expect(isLowConfidence(35)).toBe(true);
+ expect(isLowConfidence(62)).toBe(false);
+ });
+ it("handles the 0..1 scale (identificationConfidence)", () => {
+ expect(isLowConfidence(0.3)).toBe(true);
+ expect(isLowConfidence(0.55)).toBe(false);
+ });
+ it("treats missing confidence as low", () => {
+ expect(isLowConfidence(null)).toBe(true);
+ expect(isLowConfidence(undefined)).toBe(true);
+ });
+});
+
+describe("isClosingStale", () => {
+ const now = new Date("2026-08-06T12:00:00Z");
+ it("flags ACTIVE listings whose closingAt is in the past", () => {
+ expect(isClosingStale(new Date("2026-08-05T12:00:00Z"), "ACTIVE", now)).toBe(true);
+ });
+ it("does not flag open or ended listings", () => {
+ expect(isClosingStale(new Date("2026-08-07T12:00:00Z"), "ACTIVE", now)).toBe(false);
+ expect(isClosingStale(new Date("2026-08-05T12:00:00Z"), "ENDED", now)).toBe(false);
+ expect(isClosingStale(null, "ACTIVE", now)).toBe(false);
+ });
+});
diff --git a/src/agents/skeptic-rules.ts b/src/agents/skeptic-rules.ts
new file mode 100644
index 0000000..2c893b6
--- /dev/null
+++ b/src/agents/skeptic-rules.ts
@@ -0,0 +1,69 @@
+// Pure, testable rule logic for the skeptic agent. No DB, no I/O.
+
+export interface ValuationTuple {
+ listingId: string;
+ title: string;
+ /** Research.newRetail, USD */
+ newRetail: number | null;
+ /** CostBreakdown.expectedNetProfit, USD */
+ expectedNetProfit: number | null;
+ /** CostBreakdown.roi, ratio (0.42 = 42%) */
+ roi: number | null;
+}
+
+export interface CloneGroup {
+ /** Human-readable tuple key, e.g. "$900 retail / $189 net / 214% ROI" */
+ key: string;
+ members: ValuationTuple[];
+}
+
+/**
+ * Detect the placeholder-valuation artifact: groups of >= minGroup listings
+ * whose (newRetail, expectedNetProfit, roi) tuple is identical to the dollar
+ * (ROI compared to 0.1%). Distinct items genuinely never share all three.
+ */
+export function findCloneGroups(items: ValuationTuple[], minGroup = 3): CloneGroup[] {
+ const buckets = new Map<string, ValuationTuple[]>();
+ for (const it of items) {
+ if (it.newRetail == null || it.expectedNetProfit == null || it.roi == null) continue;
+ const key = [
+ Math.round(it.newRetail),
+ Math.round(it.expectedNetProfit),
+ Math.round(it.roi * 1000), // 0.1% resolution
+ ].join("|");
+ const arr = buckets.get(key);
+ if (arr) arr.push(it);
+ else buckets.set(key, [it]);
+ }
+
+ const groups: CloneGroup[] = [];
+ for (const members of buckets.values()) {
+ if (members.length < minGroup) continue;
+ const m = members[0];
+ groups.push({
+ key: `$${Math.round(m.newRetail!)} retail / $${Math.round(m.expectedNetProfit!)} net / ${Math.round(m.roi! * 100)}% ROI`,
+ members,
+ });
+ }
+ // Biggest artifact first.
+ groups.sort((a, b) => b.members.length - a.members.length);
+ return groups;
+}
+
+/** Identification/valuation confidence below the floor (both scales accepted). */
+export function isLowConfidence(confidence: number | null | undefined, floor = 0.4): boolean {
+ if (confidence == null) return true; // no confidence recorded at all
+ // Research.confidenceScore is 0..100; identificationConfidence is 0..1.
+ const ratio = confidence > 1 ? confidence / 100 : confidence;
+ return ratio < floor;
+}
+
+/** closesAt in the past but the listing still marked open/ACTIVE. */
+export function isClosingStale(
+ closingAt: Date | string | null | undefined,
+ listingStatus: string,
+ now: Date = new Date(),
+): boolean {
+ if (!closingAt) return false;
+ return listingStatus === "ACTIVE" && new Date(closingAt).getTime() < now.getTime();
+}
diff --git a/src/agents/skeptic.ts b/src/agents/skeptic.ts
new file mode 100644
index 0000000..aa21a57
--- /dev/null
+++ b/src/agents/skeptic.ts
@@ -0,0 +1,93 @@
+import { prisma } from "@/lib/db";
+import { runAgent, type AgentRunResult } from "./framework";
+import { topRankedOpportunities } from "@/lib/digest-top";
+import { findCloneGroups, isClosingStale, isLowConfidence, type ValuationTuple } from "./skeptic-rules";
+
+// The Skeptic is the adversarial digest gate. It loads the top-ranked
+// opportunities exactly the way the digest does (shared helper in
+// src/lib/digest-top.ts) and flags what a buyer should NOT trust:
+// CLONE_VALUATION (CRITICAL) — >=3 listings with dollar-identical
+// (newRetail, expectedNetProfit, roi): the placeholder-heuristic artifact.
+// LOW_CONFIDENCE (WARN) — identification/valuation confidence < 0.4.
+// CLOSING_DATA_STALE (WARN) — closingAt in the past but still marked open.
+
+const num = (d: unknown): number | null => (d == null ? null : Number(d));
+
+export async function runSkeptic(topN = 30): Promise<AgentRunResult> {
+ return runAgent("skeptic", async (ctx) => {
+ // openOnly:false so stale-but-still-ranked rows are audited too.
+ const rows = await topRankedOpportunities({ limit: topN, openOnly: false });
+ const ids = rows.map((r) => r.id);
+ const listings = await prisma.listing.findMany({
+ where: { id: { in: ids } },
+ include: { research: true, costBreakdown: true },
+ });
+ const byId = new Map(listings.map((l) => [l.id, l]));
+
+ const flagged = new Set<string>();
+ ctx.count(rows.length);
+
+ // ── CLONE_VALUATION ──
+ const tuples: ValuationTuple[] = [];
+ for (const r of rows) {
+ const l = byId.get(r.id);
+ if (!l) continue;
+ tuples.push({
+ listingId: l.id,
+ title: l.title,
+ newRetail: num(l.research?.newRetail),
+ expectedNetProfit: num(l.costBreakdown?.expectedNetProfit),
+ roi: num(l.costBreakdown?.roi),
+ });
+ }
+ for (const group of findCloneGroups(tuples)) {
+ for (const m of group.members) {
+ flagged.add(m.listingId);
+ await ctx.finding({
+ kind: "CLONE_VALUATION",
+ severity: "CRITICAL",
+ listingId: m.listingId,
+ listingTitle: m.title,
+ title: `Clone valuation: ${group.members.length} top listings share ${group.key}`,
+ detail:
+ `Identical-to-the-dollar valuation tuple across ${group.members.length} listings — ` +
+ `placeholder heuristic, not item-specific research. Members: ` +
+ group.members.map((x) => x.title.slice(0, 50)).join(" | "),
+ });
+ }
+ }
+
+ // ── LOW_CONFIDENCE + CLOSING_DATA_STALE ──
+ for (const r of rows) {
+ const l = byId.get(r.id);
+ if (!l) continue;
+
+ const confidence = num(l.research?.confidenceScore);
+ if (isLowConfidence(confidence)) {
+ flagged.add(l.id);
+ await ctx.finding({
+ kind: "LOW_CONFIDENCE",
+ severity: "WARN",
+ listingId: l.id,
+ listingTitle: l.title,
+ title: `Low confidence (${confidence == null ? "none" : Math.round(confidence) + "/100"}) on "${l.title.slice(0, 70)}"`,
+ detail: "Identification/valuation confidence below 0.4 — verify comps before trusting the numbers.",
+ });
+ }
+
+ if (isClosingStale(l.closingAt, l.listingStatus)) {
+ flagged.add(l.id);
+ await ctx.finding({
+ kind: "CLOSING_DATA_STALE",
+ severity: "WARN",
+ listingId: l.id,
+ listingTitle: l.title,
+ title: `Closing data stale: "${l.title.slice(0, 70)}" closed ${l.closingAt?.toISOString().slice(0, 10)} but is still ACTIVE`,
+ detail: "closingAt is in the past while listingStatus=ACTIVE — the liveness sweep missed it or the source changed.",
+ });
+ }
+ }
+
+ return `${flagged.size}/${rows.length} top opportunities flagged`;
+ });
+}
diff --git a/src/app/agent-ops/page.tsx b/src/app/agent-ops/page.tsx
new file mode 100644
index 0000000..73ea902
--- /dev/null
+++ b/src/app/agent-ops/page.tsx
@@ -0,0 +1,171 @@
+import Link from "next/link";
+import { prisma } from "@/lib/db";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+
+export const dynamic = "force-dynamic";
+
+// Ops console for the agent layer: one card per agent (last run, status,
+// items, summary) + the latest findings across all agents.
+
+const AGENTS = ["appraiser", "skeptic", "scout"] as const;
+
+const AGENT_BLURBS: Record<(typeof AGENTS)[number], string> = {
+ appraiser: "Re-researches weakly-identified listings with the local AI so valuations are item-specific.",
+ skeptic: "Adversarial digest gate — flags clone valuations, low confidence, stale closing data in the top-ranked set.",
+ scout: "Hot deals closing within 48h at score ≥ 75; CRITICAL under 6h.",
+};
+
+const fmtWhen = (d: Date) =>
+ d.toLocaleString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+
+function statusVariant(status: string) {
+ if (status === "OK") return "positive" as const;
+ if (status === "FAILED") return "negative" as const;
+ return "warning" as const; // RUNNING
+}
+
+function severityVariant(severity: string) {
+ if (severity === "CRITICAL") return "negative" as const;
+ if (severity === "WARN") return "warning" as const;
+ return "primary" as const; // INFO
+}
+
+export default async function AgentOpsPage() {
+ const [lastRuns, findings] = await Promise.all([
+ Promise.all(
+ AGENTS.map((agent) =>
+ prisma.agentRun.findFirst({
+ where: { agent },
+ orderBy: { startedAt: "desc" },
+ include: { _count: { select: { findings: true } } },
+ }),
+ ),
+ ),
+ prisma.agentFinding.findMany({ orderBy: { createdAt: "desc" }, take: 100 }),
+ ]);
+
+ return (
+ <main className="mx-auto max-w-[1400px] space-y-5 p-5">
+ <header className="flex items-center justify-between">
+ <div>
+ <h1 className="text-xl font-semibold tracking-tight">
+ Agent <span className="text-primary">Ops</span>
+ </h1>
+ <p className="text-sm text-muted-foreground">
+ Appraiser · Skeptic · Scout — run history and findings for the AI agent layer.
+ </p>
+ </div>
+ <nav className="flex items-center gap-4 text-sm">
+ <Link href="/" className="text-primary underline-offset-4 hover:underline">
+ ← Dashboard
+ </Link>
+ </nav>
+ </header>
+
+ <section className="grid gap-4 md:grid-cols-3">
+ {AGENTS.map((agent, i) => {
+ const run = lastRuns[i];
+ return (
+ <Card key={agent}>
+ <CardHeader className="flex-row items-center justify-between">
+ <CardTitle className="capitalize text-foreground">{agent}</CardTitle>
+ {run ? (
+ <Badge variant={statusVariant(run.status)}>{run.status}</Badge>
+ ) : (
+ <Badge variant="outline">never run</Badge>
+ )}
+ </CardHeader>
+ <CardContent className="space-y-2 text-sm">
+ <p className="text-xs text-muted-foreground">{AGENT_BLURBS[agent]}</p>
+ {run ? (
+ <>
+ <div className="text-muted-foreground" title={run.startedAt.toISOString()}>
+ 🕓 {fmtWhen(run.startedAt)}
+ {run.finishedAt
+ ? ` · ${Math.max(1, Math.round((run.finishedAt.getTime() - run.startedAt.getTime()) / 1000))}s`
+ : " · running"}
+ </div>
+ <div>
+ {run.itemsProcessed} items · {run._count.findings} finding{run._count.findings === 1 ? "" : "s"}
+ </div>
+ {run.summary && <p className="text-muted-foreground">{run.summary}</p>}
+ </>
+ ) : (
+ <p className="text-muted-foreground">
+ Run with <code className="rounded bg-accent px-1">npm run agents -- {agent}</code>
+ </p>
+ )}
+ </CardContent>
+ </Card>
+ );
+ })}
+ </section>
+
+ <Card>
+ <CardHeader>
+ <CardTitle>Latest findings ({findings.length})</CardTitle>
+ </CardHeader>
+ <CardContent className="overflow-x-auto">
+ {findings.length === 0 ? (
+ <p className="text-sm text-muted-foreground">No findings yet — run an agent to populate this table.</p>
+ ) : (
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="border-b text-left text-xs text-muted-foreground">
+ <th className="py-2 pr-3 font-medium">Time</th>
+ <th className="py-2 pr-3 font-medium">Agent</th>
+ <th className="py-2 pr-3 font-medium">Severity</th>
+ <th className="py-2 pr-3 font-medium">Kind</th>
+ <th className="py-2 pr-3 font-medium">Finding</th>
+ <th className="py-2 font-medium">Listing</th>
+ </tr>
+ </thead>
+ <tbody>
+ {findings.map((f) => (
+ <tr key={f.id} className="border-b align-top last:border-0">
+ <td
+ className="whitespace-nowrap py-2 pr-3 text-muted-foreground"
+ title={f.createdAt.toISOString()}
+ >
+ {fmtWhen(f.createdAt)}
+ </td>
+ <td className="py-2 pr-3 capitalize">{f.agent}</td>
+ <td className="py-2 pr-3">
+ <Badge variant={severityVariant(f.severity)}>{f.severity}</Badge>
+ </td>
+ <td className="whitespace-nowrap py-2 pr-3 text-xs text-muted-foreground">{f.kind}</td>
+ <td className="max-w-[560px] py-2 pr-3">
+ <div>{f.title}</div>
+ {f.detail && (
+ <div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{f.detail}</div>
+ )}
+ </td>
+ <td className="py-2">
+ {f.listingId ? (
+ <Link
+ href={`/listings/${f.listingId}`}
+ className="text-primary underline-offset-4 hover:underline"
+ >
+ {(f.listingTitle || "listing").slice(0, 40)} →
+ </Link>
+ ) : (
+ <span className="text-muted-foreground">—</span>
+ )}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ )}
+ </CardContent>
+ </Card>
+ </main>
+ );
+}
diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx
new file mode 100644
index 0000000..ae7cae4
--- /dev/null
+++ b/src/app/agents/page.tsx
@@ -0,0 +1,59 @@
+import Link from "next/link";
+import { getCurrentUser } from "@/lib/current-user";
+import { LogoutButton } from "@/components/logout-button";
+import { AgentFinder } from "@/components/agent-finder";
+
+export const dynamic = "force-dynamic";
+
+export const metadata = {
+ title: "Commercial Real Estate Agents",
+ description:
+ "Find commercial real-estate buyer / acquisition brokers and leasing agents in any market, sourced live from Google Places — a standalone commercial-property resource.",
+};
+
+// Deep-linkable from a listing: /agents?city=…&state=…&zip=…&type=both
+export default async function AgentsPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ city?: string; state?: string; zip?: string; type?: string }>;
+}) {
+ const [user, sp] = await Promise.all([getCurrentUser(), searchParams]);
+ const type = sp.type === "buyer" || sp.type === "leasing" ? sp.type : "both";
+
+ return (
+ <main className="mx-auto max-w-[1100px] space-y-5 p-5">
+ <header className="flex items-center justify-between">
+ <div>
+ <h1 className="text-xl font-semibold tracking-tight">
+ Commercial <span className="text-primary">Agents</span>
+ </h1>
+ <p className="text-sm text-muted-foreground">
+ Commercial-only buyer / acquisition brokers & leasing agents by location — live from Google Places. A
+ standalone research resource — not tied to the surplus listings on your dashboard.
+ </p>
+ </div>
+ <nav className="flex items-center gap-4 text-sm">
+ <Link href="/" className="text-primary underline-offset-4 hover:underline">
+ ← Dashboard
+ </Link>
+ <Link href="/selling-avenues" className="text-primary underline-offset-4 hover:underline">
+ Selling avenues →
+ </Link>
+ {user && (
+ <span className="flex items-center gap-2 text-muted-foreground">
+ {user.email} · {user.role}
+ <LogoutButton />
+ </span>
+ )}
+ </nav>
+ </header>
+
+ <AgentFinder
+ initialCity={sp.city ?? ""}
+ initialState={sp.state ?? ""}
+ initialZip={sp.zip ?? ""}
+ initialType={type}
+ />
+ </main>
+ );
+}
diff --git a/src/app/api/agents/search/route.ts b/src/app/api/agents/search/route.ts
new file mode 100644
index 0000000..5fd8c65
--- /dev/null
+++ b/src/app/api/agents/search/route.ts
@@ -0,0 +1,68 @@
+import { NextRequest, NextResponse } from "next/server";
+import { searchCommercialAgents, type AgentKind } from "@/lib/places";
+import { rateLimit, clientIp, sessionOrIpKey } from "@/lib/rate-limit";
+import { getCurrentUser } from "@/lib/current-user";
+
+export const dynamic = "force-dynamic";
+
+// Rate-limit bucket key. The upstream (Google Places) is a paid API, so one
+// session must not be able to run up the bill. Key on the VERIFIED session
+// subject (unforgeable) — NOT on X-Forwarded-For, which the client controls and
+// can rotate to reset the bucket. This route is login-gated by middleware, so a
+// user is normally present; fall back to the (spoofable) IP only as a last resort.
+async function clientKey(req: NextRequest): Promise<string> {
+ const user = await getCurrentUser();
+ return sessionOrIpKey("agents", user?.sub, clientIp(req));
+}
+
+// Lightweight input validation so user-supplied location can't pad the Places
+// textQuery with junk (cost/relevance noise) or a malformed ZIP.
+const ZIP_RE = /^\d{5}(?:-\d{4})?$/;
+const LOC_RE = /^[a-zA-Z0-9\s,.'-]{1,80}$/;
+
+export async function GET(req: NextRequest) {
+ const rl = rateLimit(await clientKey(req), 20, 60_000);
+ if (!rl.allowed) {
+ return NextResponse.json(
+ { error: "Too many searches — wait a moment." },
+ { status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } },
+ );
+ }
+
+ const sp = req.nextUrl.searchParams;
+ const city = sp.get("city")?.trim() || undefined;
+ const state = sp.get("state")?.trim() || undefined;
+ const zip = sp.get("zip")?.trim() || undefined;
+ const typeParam = (sp.get("type") ?? "both").toLowerCase();
+ const kinds: AgentKind[] =
+ typeParam === "buyer" ? ["buyer"] : typeParam === "leasing" ? ["leasing"] : ["buyer", "leasing"];
+
+ if (!city && !state && !zip) {
+ return NextResponse.json({ error: "Provide a city, state, or ZIP." }, { status: 400 });
+ }
+ if (zip && !ZIP_RE.test(zip)) {
+ return NextResponse.json({ error: "Invalid ZIP — use 5 digits (or ZIP+4)." }, { status: 400 });
+ }
+ if (city && !LOC_RE.test(city)) {
+ return NextResponse.json({ error: "Invalid city." }, { status: 400 });
+ }
+ if (state && !LOC_RE.test(state)) {
+ return NextResponse.json({ error: "Invalid state." }, { status: 400 });
+ }
+
+ try {
+ const data = await searchCommercialAgents({ city, state, zip, kinds });
+ return NextResponse.json(data);
+ } catch (e) {
+ // Don't leak the raw upstream (Google) error to the client — it can reveal
+ // key/quota details. Log server-side; return a generic message. Preserve the
+ // 503 "not configured" signal so ops can tell a missing key from a bad call.
+ const raw = e instanceof Error ? e.message : "Search failed";
+ console.error("[agents/search] upstream error:", raw);
+ const notConfigured = /not configured/.test(raw);
+ return NextResponse.json(
+ { error: notConfigured ? "Agent search is temporarily unavailable." : "Search failed — try again." },
+ { status: notConfigured ? 503 : 502 },
+ );
+ }
+}
diff --git a/src/app/api/auth/apple/route.ts b/src/app/api/auth/apple/route.ts
new file mode 100644
index 0000000..90e62d9
--- /dev/null
+++ b/src/app/api/auth/apple/route.ts
@@ -0,0 +1,104 @@
+import { NextRequest, NextResponse } from "next/server";
+import { randomBytes } from "node:crypto";
+import { z } from "zod";
+import { createRemoteJWKSet, jwtVerify } from "jose";
+import { prisma } from "@/lib/db";
+import { hashPassword } from "@/lib/password";
+import { createSession, SESSION_COOKIE, SESSION_MAX_AGE } from "@/lib/session";
+import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+// Sign in with Apple — NATIVE iOS flow. The iOS app (expo-apple-authentication)
+// obtains an Apple identity token whose `aud` is the app's bundle id; we verify
+// it against Apple's public JWKS and issue our OWN app session JWT in the JSON
+// body (the native app stores it and sends `Authorization: Bearer <jwt>`). A
+// session cookie is also set for web parity. Optional sign-in: the rest of the
+// app works logged-out; this just creates/links a lightweight account.
+const APPLE_ISSUER = "https://appleid.apple.com";
+const APPLE_JWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/keys"));
+// The app's bundle id is the `aud` of a native Sign in with Apple token.
+const APPLE_AUDIENCE = process.env.APPLE_BUNDLE_ID || "com.abrams.govarbitrage";
+
+// Apple returns name/email ONLY on the very first authorization; the app passes
+// them through so we can populate the account on create.
+const Schema = z.object({
+ identityToken: z.string().min(1),
+ fullName: z.string().trim().max(200).optional(),
+ email: z.string().email().max(320).optional(),
+});
+
+export async function POST(req: NextRequest) {
+ // Brute-force / abuse protection, mirroring the password login route.
+ const ip = clientIp(req);
+ const ipLimit = rateLimit(`apple:ip:${ip}`, 20, 5 * 60_000);
+ if (!ipLimit.allowed) return tooManyRequests(ipLimit);
+
+ const parsed = Schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "identityToken required" }, { status: 400 });
+ }
+ const { identityToken, fullName, email: providedEmail } = parsed.data;
+
+ // Verify the Apple identity token: signature (Apple JWKS) + issuer + audience.
+ let appleSub: string;
+ let tokenEmail: string | undefined;
+ try {
+ const { payload } = await jwtVerify(identityToken, APPLE_JWKS, {
+ issuer: APPLE_ISSUER,
+ audience: APPLE_AUDIENCE,
+ });
+ if (!payload.sub) throw new Error("no sub");
+ appleSub = payload.sub;
+ tokenEmail = typeof payload.email === "string" ? payload.email : undefined;
+ } catch {
+ await prisma.auditLog.create({
+ data: { action: "auth.apple.failed", entity: "User", meta: { reason: "token_verify" } },
+ });
+ return NextResponse.json({ error: "Invalid Apple token" }, { status: 401 });
+ }
+
+ // Prefer the verified email from the token, then the app-provided one, else a
+ // stable synthetic (Apple can withhold email on re-auth / private relay).
+ const email = (tokenEmail || providedEmail || `apple_${appleSub}@govarbitrage.apple`).toLowerCase();
+
+ // Upsert the account by Apple `sub`. Apple users never password-login, so give
+ // them an unguessable random passwordHash (keeps the column non-null without a
+ // migration and without a usable password).
+ let user = await prisma.user.findUnique({ where: { appleSub } });
+ if (!user) {
+ user = await prisma.user.create({
+ data: {
+ appleSub,
+ email,
+ name: fullName || null,
+ passwordHash: hashPassword(randomBytes(32).toString("hex")),
+ role: "VIEWER",
+ },
+ });
+ await prisma.auditLog.create({
+ data: { userId: user.id, action: "auth.apple.signup", entity: "User", entityId: user.id },
+ });
+ } else if (fullName && !user.name) {
+ // Backfill the name if Apple only sent it now and we didn't have it.
+ user = await prisma.user.update({ where: { id: user.id }, data: { name: fullName } });
+ }
+
+ const token = await createSession({ sub: user.id, email: user.email, role: user.role });
+ await prisma.auditLog.create({
+ data: { userId: user.id, action: "auth.apple.login", entity: "User", entityId: user.id },
+ });
+
+ const res = NextResponse.json({
+ token, // native app stores this and sends it as `Authorization: Bearer <token>`
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
+ });
+ res.cookies.set(SESSION_COOKIE, token, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+ path: "/",
+ maxAge: SESSION_MAX_AGE,
+ });
+ return res;
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..4eebad2
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -0,0 +1,73 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@/lib/db";
+import { verifyPassword, hashPassword } from "@/lib/password";
+import { createSession, SESSION_COOKIE, SESSION_MAX_AGE } from "@/lib/session";
+import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+// A well-formed dummy hash so verifyPassword runs the full scrypt path even
+// when the user doesn't exist — keeps login timing uniform (no user-enumeration
+// oracle). Computed once at module load.
+const DUMMY_HASH = hashPassword("invalid-password-placeholder");
+
+// Username-only login (Steve 2026-07-22: "no more email login"). The username
+// is resolved against the unique user whose email local-part matches
+// ("admin" → admin@govarbitrage.local). Full emails are rejected outright.
+const Schema = z.object({ username: z.string().trim().min(1), password: z.string().min(1) });
+
+async function resolveUser(username: string) {
+ const id = username.toLowerCase();
+ if (id.includes("@")) return null; // email-style logins are retired
+ const matches = await prisma.user.findMany({
+ where: { email: { startsWith: `${id}@` } },
+ take: 2,
+ });
+ return matches.length === 1 ? matches[0] : null;
+}
+
+export async function POST(req: NextRequest) {
+ // Brute-force protection: cap login attempts per IP.
+ const ip = clientIp(req);
+ const ipLimit = rateLimit(`login:ip:${ip}`, 10, 5 * 60_000);
+ if (!ipLimit.allowed) return tooManyRequests(ipLimit);
+
+ const parsed = Schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Username and password required" }, { status: 400 });
+ }
+ const { username, password } = parsed.data;
+
+ // And per targeted username (credential-stuffing protection).
+ const userLimit = rateLimit(`login:user:${username.toLowerCase()}`, 5, 15 * 60_000);
+ if (!userLimit.allowed) return tooManyRequests(userLimit);
+ const user = await resolveUser(username);
+
+ // Always run a full scrypt verification (against a dummy hash when the user
+ // is absent) so response timing doesn't reveal whether the account exists.
+ const ok = verifyPassword(password, user ? user.passwordHash : DUMMY_HASH);
+ if (!user || !ok) {
+ await prisma.auditLog.create({
+ data: { action: "auth.login.failed", entity: "User", meta: { username } },
+ });
+ return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+ }
+
+ const token = await createSession({ sub: user.id, email: user.email, role: user.role });
+ await prisma.auditLog.create({
+ data: { userId: user.id, action: "auth.login", entity: "User", entityId: user.id },
+ });
+
+ const res = NextResponse.json({
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
+ });
+ res.cookies.set(SESSION_COOKIE, token, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+ path: "/",
+ maxAge: SESSION_MAX_AGE,
+ });
+ return res;
+}
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..21eb169
--- /dev/null
+++ b/src/app/api/auth/logout/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from "next/server";
+import { SESSION_COOKIE } from "@/lib/session";
+
+export const dynamic = "force-dynamic";
+
+export async function POST() {
+ const res = NextResponse.json({ ok: true });
+ res.cookies.set(SESSION_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
+ return res;
+}
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
new file mode 100644
index 0000000..8259595
--- /dev/null
+++ b/src/app/api/auth/me/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from "next/server";
+import { getCurrentUser } from "@/lib/current-user";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const user = await getCurrentUser();
+ if (!user) return NextResponse.json({ user: null }, { status: 401 });
+ return NextResponse.json({ user: { id: user.sub, email: user.email, role: user.role } });
+}
diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts
new file mode 100644
index 0000000..0aa2cb2
--- /dev/null
+++ b/src/app/api/billing/checkout/route.ts
@@ -0,0 +1,22 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getCurrentUser } from "@/lib/current-user";
+import { createCheckout } from "@/lib/billing";
+import type { Tier } from "@prisma/client";
+
+export const dynamic = "force-dynamic";
+
+// Start a (TEST/mock) subscription checkout for the chosen tier.
+export async function POST(req: NextRequest) {
+ const user = await getCurrentUser();
+ if (!user) return NextResponse.json({ error: "sign in first" }, { status: 401 });
+
+ const { tier } = (await req.json().catch(() => ({}))) as { tier?: Tier };
+ if (tier !== "STANDARD" && tier !== "PREMIUM") {
+ return NextResponse.json({ error: "tier must be STANDARD or PREMIUM" }, { status: 400 });
+ }
+
+ const baseUrl = process.env.PUBLIC_BASE_URL || req.nextUrl.origin;
+ const result = await createCheckout({ userId: user.sub, email: user.email, tier, baseUrl });
+ if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
+ return NextResponse.json({ url: result.url, mode: result.mode });
+}
diff --git a/src/app/api/billing/webhook/route.ts b/src/app/api/billing/webhook/route.ts
new file mode 100644
index 0000000..fb6be2c
--- /dev/null
+++ b/src/app/api/billing/webhook/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server";
+import { verifyWebhook } from "@/lib/billing";
+import { prisma } from "@/lib/db";
+import { tierFromPriceUsd } from "@/lib/tiers";
+import type { Tier } from "@prisma/client";
+
+export const dynamic = "force-dynamic";
+
+// Stripe TEST webhook — the real source of truth for subscription state.
+// checkout.session.completed / subscription updates -> set user.tier.
+export async function POST(req: NextRequest) {
+ const raw = await req.text();
+ const event = verifyWebhook(raw, req.headers.get("stripe-signature"));
+ if (!event) return NextResponse.json({ error: "unverified" }, { status: 400 });
+
+ try {
+ if (event.type === "checkout.session.completed") {
+ const s = event.data.object as {
+ client_reference_id?: string | null;
+ metadata?: { userId?: string; tier?: string } | null;
+ customer?: string | null;
+ subscription?: string | null;
+ amount_total?: number | null;
+ };
+ const userId = s.metadata?.userId || s.client_reference_id || undefined;
+ const tier = (s.metadata?.tier as Tier) || tierFromPriceUsd((s.amount_total ?? 0) / 100);
+ if (userId && (tier === "STANDARD" || tier === "PREMIUM")) {
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ tier,
+ stripeCustomerId: (s.customer as string) || undefined,
+ stripeSubscriptionId: (s.subscription as string) || undefined,
+ },
+ });
+ }
+ } else if (event.type === "customer.subscription.deleted") {
+ const sub = event.data.object as { id: string };
+ await prisma.user.updateMany({
+ where: { stripeSubscriptionId: sub.id },
+ data: { tier: "FREE", stripeSubscriptionId: null },
+ });
+ }
+ } catch (e) {
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
+ }
+ return NextResponse.json({ received: true });
+}
diff --git a/src/app/api/import/apify-govdeals/route.ts b/src/app/api/import/apify-govdeals/route.ts
new file mode 100644
index 0000000..26455c8
--- /dev/null
+++ b/src/app/api/import/apify-govdeals/route.ts
@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from "next/server";
+import { fetchApifyDataset, fetchApifyLastRun } from "@/importers/apify-govdeals";
+import { ingestMany } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 300;
+
+// Ingest GovDeals listings scraped by Apify. READ-ONLY: pulls an already-finished
+// dataset (?dataset=ID) or the actor's last successful run (free). Triggering a
+// NEW Apify run costs money and is intentionally NOT exposed here — it stays a
+// deliberate, cost-surfaced action. ?ai=1 runs local Ollama identification.
+export async function POST(req: NextRequest) {
+ const denied = await requireWrite(req);
+ if (denied) return denied;
+ try {
+ const datasetId = req.nextUrl.searchParams.get("dataset") || undefined;
+ const useAI = req.nextUrl.searchParams.get("ai") === "1";
+ const rows = datasetId ? await fetchApifyDataset(datasetId) : await fetchApifyLastRun();
+ const results = await ingestMany(rows, { useAI });
+ const created = results.filter((r) => r.ok && r.created).length;
+ const updated = results.filter((r) => r.ok && !r.created).length;
+ const failed = results.filter((r) => !r.ok).length;
+ return NextResponse.json({ source: "GOVDEALS", via: "apify", fetched: rows.length, created, updated, failed });
+ } catch (e) {
+ return NextResponse.json({ error: (e as Error).message }, { status: 502 });
+ }
+}
diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts
new file mode 100644
index 0000000..837bb3a
--- /dev/null
+++ b/src/app/api/import/csv/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+import { parseListingsCsv } from "@/importers/csv";
+import { ingestMany } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 60;
+
+// Accepts either raw CSV text (Content-Type: text/csv) or a multipart file upload
+// under the "file" field. Set ?research=false to skip the research pipeline.
+export async function POST(req: NextRequest) {
+ const denied = await requireWrite(req);
+ if (denied) return denied;
+ try {
+ const research = req.nextUrl.searchParams.get("research") !== "false";
+ let text: string;
+
+ const ct = req.headers.get("content-type") || "";
+ if (ct.includes("multipart/form-data")) {
+ const form = await req.formData();
+ const file = form.get("file");
+ if (!(file instanceof File)) {
+ return NextResponse.json({ error: "No file field" }, { status: 400 });
+ }
+ text = await file.text();
+ } else {
+ text = await req.text();
+ }
+
+ if (!text.trim()) return NextResponse.json({ error: "Empty CSV" }, { status: 400 });
+
+ const rows = parseListingsCsv(text);
+ if (rows.length === 0) return NextResponse.json({ error: "No rows parsed" }, { status: 400 });
+
+ const results = await ingestMany(rows, { research });
+ const created = results.filter((r) => r.ok && r.created).length;
+ const updated = results.filter((r) => r.ok && !r.created).length;
+ const failed = results.filter((r) => !r.ok);
+
+ return NextResponse.json({ total: rows.length, created, updated, failed, results });
+ } catch (e) {
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/import/extension/route.ts b/src/app/api/import/extension/route.ts
new file mode 100644
index 0000000..410581d
--- /dev/null
+++ b/src/app/api/import/extension/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { ingest, type RawListing } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+// Receives a captured auction page from the browser extension (content.js shape)
+// and ingests it (create/update + research). CORS-open so the extension can POST.
+const CORS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, x-import-token",
+};
+
+export function OPTIONS() {
+ return new NextResponse(null, { headers: CORS });
+}
+
+export async function POST(req: NextRequest) {
+ const denied = await requireWrite(req);
+ if (denied) return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: CORS });
+ try {
+ const body = (await req.json()) as RawListing;
+ if (!body?.title || !body?.sourceAuctionId) {
+ return NextResponse.json(
+ { error: "Missing title or sourceAuctionId" },
+ { status: 400, headers: CORS },
+ );
+ }
+ const result = await ingest({ ...body, source: body.source || "EXTENSION" });
+ return NextResponse.json(result, { headers: CORS });
+ } catch (e) {
+ return NextResponse.json({ error: (e as Error).message }, { status: 500, headers: CORS });
+ }
+}
diff --git a/src/app/api/import/gsa/route.ts b/src/app/api/import/gsa/route.ts
new file mode 100644
index 0000000..0cec7dd
--- /dev/null
+++ b/src/app/api/import/gsa/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from "next/server";
+import { fetchGsaAuctions } from "@/importers/gsa";
+import { ingestMany } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 120;
+
+// Pull live listings from the official GSA Auctions API and ingest them.
+// Auth-gated (session or x-import-token). ?limit=N&ai=1 (ai defaults off for speed).
+export async function POST(req: NextRequest) {
+ const denied = await requireWrite(req);
+ if (denied) return denied;
+ try {
+ const limit = Number(req.nextUrl.searchParams.get("limit") || 40);
+ const useAI = req.nextUrl.searchParams.get("ai") === "1";
+ const rows = await fetchGsaAuctions({ limit });
+ const results = await ingestMany(rows, { useAI });
+ const created = results.filter((r) => r.ok && r.created).length;
+ const updated = results.filter((r) => r.ok && !r.created).length;
+ const failed = results.filter((r) => !r.ok);
+ return NextResponse.json({ source: "GSA_AUCTIONS", fetched: rows.length, created, updated, failed: failed.length });
+ } catch (e) {
+ return NextResponse.json({ error: (e as Error).message }, { status: 502 });
+ }
+}
diff --git a/src/app/api/import/url/route.ts b/src/app/api/import/url/route.ts
new file mode 100644
index 0000000..0c964e8
--- /dev/null
+++ b/src/app/api/import/url/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest, NextResponse } from "next/server";
+import { scrapeUrl } from "@/importers/scrape";
+import { ingest } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+import { assertPublicUrl } from "@/lib/ssrf-guard";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+export const maxDuration = 60;
+
+// Scrape a single auction URL (Playwright best-effort) and ingest it.
+export async function POST(req: NextRequest) {
+ const denied = await requireWrite(req);
+ if (denied) return denied;
+ try {
+ const { url } = (await req.json()) as { url?: string };
+ if (!url) return NextResponse.json({ error: "Missing url" }, { status: 400 });
+ // SSRF guard: reject metadata/loopback/private targets before scraping.
+ try {
+ await assertPublicUrl(url);
+ } catch {
+ return NextResponse.json({ error: "URL not allowed" }, { status: 400 });
+ }
+ const raw = await scrapeUrl(url);
+ const result = await ingest(raw);
+ return NextResponse.json({ ...result, raw });
+ } catch (e) {
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/listings/[id]/buyer-lead/route.ts b/src/app/api/listings/[id]/buyer-lead/route.ts
new file mode 100644
index 0000000..743d433
--- /dev/null
+++ b/src/app/api/listings/[id]/buyer-lead/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/db";
+import { z } from "zod";
+import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+const LeadSchema = z.object({
+ name: z.string().min(1),
+ email: z.string().email(),
+ phone: z.string().optional(),
+ offer: z.number().nonnegative().optional(),
+ notes: z.string().optional(),
+});
+
+// Record a CONTINGENT, non-binding buyer lead against a listing.
+export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ // Public endpoint — throttle anonymous submissions to curb spam/abuse.
+ const limit = rateLimit(`lead:ip:${clientIp(req)}`, 5, 10 * 60_000);
+ if (!limit.allowed) return tooManyRequests(limit);
+
+ const { id } = await params;
+ const listing = await prisma.listing.findUnique({ where: { id } });
+ if (!listing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const parsed = LeadSchema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid lead", issues: parsed.error.issues }, { status: 400 });
+ }
+ const lead = await prisma.buyerLead.create({
+ data: { listingId: id, ...parsed.data, contingent: true },
+ });
+ await prisma.listingEvent.create({
+ data: { listingId: id, type: "BUYER_LEAD_ADDED", message: `Contingent lead from ${parsed.data.name}` },
+ });
+ return NextResponse.json(lead);
+}
diff --git a/src/app/api/listings/[id]/buyer-page/route.ts b/src/app/api/listings/[id]/buyer-page/route.ts
new file mode 100644
index 0000000..e8fd225
--- /dev/null
+++ b/src/app/api/listings/[id]/buyer-page/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/db";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+function slugify(s: string) {
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
+}
+
+// Create/update a CONTINGENT buyer-interest page for a listing.
+export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const denied = await requireWrite(req);
+ if (denied) return denied;
+ const { id } = await params;
+ const listing = await prisma.listing.findUnique({ where: { id } });
+ if (!listing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = (await req.json().catch(() => ({}))) as {
+ headline?: string;
+ estDelivered?: number;
+ published?: boolean;
+ };
+ const slug = slugify(`${listing.manufacturer ?? listing.title}-${listing.sourceAuctionId}`);
+
+ const page = await prisma.buyerInterestPage.upsert({
+ where: { listingId: id },
+ create: {
+ listingId: id,
+ slug,
+ headline: body.headline ?? `${listing.title} — Contingent Interest`,
+ estDelivered: body.estDelivered ?? null,
+ published: body.published ?? true,
+ contingent: true,
+ },
+ update: {
+ headline: body.headline,
+ estDelivered: body.estDelivered,
+ published: body.published,
+ },
+ });
+ await prisma.listingEvent.create({
+ data: { listingId: id, type: "MANUAL_EDIT", message: `Buyer-interest page ${page.slug} saved` },
+ });
+ return NextResponse.json(page);
+}
diff --git a/src/app/api/listings/[id]/route.ts b/src/app/api/listings/[id]/route.ts
new file mode 100644
index 0000000..9a5de28
--- /dev/null
+++ b/src/app/api/listings/[id]/route.ts
@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getGatedListingDetail } from "@/lib/listing-detail";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const { listing, tier, gated } = await getGatedListingDetail(id);
+ if (!listing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ return NextResponse.json({ ...listing, tier, gated });
+}
diff --git a/src/app/api/listings/route.ts b/src/app/api/listings/route.ts
new file mode 100644
index 0000000..8bc98dd
--- /dev/null
+++ b/src/app/api/listings/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from "next/server";
+import { queryListings, type ListingRow, type QueryParams } from "@/lib/listings";
+import { getCurrentTier, moneyMathVisible } from "@/lib/current-user";
+import { tierDef } from "@/lib/tiers";
+
+export const dynamic = "force-dynamic";
+
+// Money-math fields redacted for any tier whose showMoneyMath is false. All
+// current tiers (FREE included) show the full analysis, so this redaction is
+// dormant infrastructure — retained so a future gated tier can null these
+// consistently. Nothing here keys off the client type.
+const REDACTABLE_FIELDS: (keyof ListingRow)[] = [
+ "recommendedMaxBid", "retailLow", "retailAverage", "retailHigh",
+ "usedLow", "usedAverage", "usedHigh", "wholesale", "liquidation", "sellNow",
+ "value7Day", "value30Day", "value90Day", "expectedSale", "netProfit", "roi",
+ "opportunityScore", "arbitrageScore", "demandScore", "velocityScore",
+ "logisticsScore", "conditionScore", "competitionScore", "buyerScore",
+];
+
+export async function GET(req: NextRequest) {
+ const sp = req.nextUrl.searchParams;
+ const params: QueryParams = {
+ search: sp.get("search") || undefined,
+ source: sp.get("source") || undefined,
+ category: sp.get("category") || undefined,
+ condition: sp.get("condition") || undefined,
+ risk: sp.get("risk") || undefined,
+ closingWithinHours: sp.get("closingWithinHours")
+ ? Number(sp.get("closingWithinHours"))
+ : undefined,
+ sort: (sp.get("sort") as keyof ListingRow) || undefined,
+ dir: (sp.get("dir") as "asc" | "desc") || undefined,
+ page: sp.get("page") ? Number(sp.get("page")) : undefined,
+ pageSize: sp.get("pageSize") ? Number(sp.get("pageSize")) : undefined,
+ profile: sp.get("profile") || undefined,
+ };
+ const tier = await getCurrentTier();
+ const limits = tierDef(tier).limits;
+ // Visibility depends on the resolved tier only — identical for every client.
+ const showMoney = await moneyMathVisible(tier);
+
+ // Enforce the tier's listing cap.
+ const capped = { ...params, pageSize: Math.min(params.pageSize ?? 50, limits.maxListings) };
+ const result = await queryListings(capped);
+
+ // Redact the money-math when the tier hides it (no tier currently does).
+ if (!showMoney) {
+ result.rows = result.rows.map((r) => {
+ const row: Record<string, unknown> = { ...r };
+ for (const f of REDACTABLE_FIELDS) row[f] = null;
+ return row as unknown as ListingRow;
+ });
+ }
+
+ return NextResponse.json({ ...result, tier, gated: !showMoney });
+}
diff --git a/src/app/api/newsletter/confirm/route.ts b/src/app/api/newsletter/confirm/route.ts
new file mode 100644
index 0000000..8cbed96
--- /dev/null
+++ b/src/app/api/newsletter/confirm/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest } from "next/server";
+import { prisma } from "@/lib/db";
+import { resultPage } from "../result-page";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(req: NextRequest) {
+ const token = req.nextUrl.searchParams.get("token") || "";
+ if (!token) {
+ return resultPage(400, "Missing link", "This confirmation link is incomplete. Please use the link from your email.");
+ }
+
+ const sub = await prisma.subscriber.findUnique({ where: { confirmToken: token } });
+ if (!sub) {
+ return resultPage(404, "Link not recognized", "This confirmation link is invalid or has been superseded by a newer one. Re-subscribe to get a fresh link.");
+ }
+
+ if (sub.status !== "CONFIRMED") {
+ await prisma.subscriber.update({
+ where: { id: sub.id },
+ data: { status: "CONFIRMED", confirmedAt: new Date() },
+ });
+ }
+
+ return resultPage(
+ 200,
+ "You're subscribed",
+ "Your email is confirmed. The Top-10 government-surplus deals digest will arrive twice daily (6am & 5pm PT). Every digest includes a one-click unsubscribe link.",
+ );
+}
diff --git a/src/app/api/newsletter/result-page.ts b/src/app/api/newsletter/result-page.ts
new file mode 100644
index 0000000..f11b538
--- /dev/null
+++ b/src/app/api/newsletter/result-page.ts
@@ -0,0 +1,22 @@
+// Small friendly HTML result page for confirm/unsubscribe links, styled to
+// match the public pricing page.
+export function resultPage(status: number, title: string, body: string): Response {
+ const html = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${title} — GovArbitrage</title>
+</head>
+<body style="margin:0;background:#f8fafc;font-family:system-ui;color:#0f172a">
+ <main style="max-width:560px;margin:96px auto;padding:0 20px;text-align:center">
+ <div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:36px 28px">
+ <h1 style="font-size:26px;margin:0 0 10px">${title}</h1>
+ <p style="color:#475569;font-size:15px;line-height:1.6;margin:0">${body}</p>
+ <p style="margin-top:22px"><a href="/newsletter" style="color:#111827;font-weight:600">← Back to the newsletter page</a></p>
+ </div>
+ </main>
+</body>
+</html>`;
+ return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
+}
diff --git a/src/app/api/newsletter/subscribe/route.ts b/src/app/api/newsletter/subscribe/route.ts
new file mode 100644
index 0000000..130c87e
--- /dev/null
+++ b/src/app/api/newsletter/subscribe/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@/lib/db";
+import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
+import {
+ buildConfirmEmail,
+ newToken,
+ normalizeEmail,
+ sendNewsletterEmail,
+} from "@/lib/newsletter";
+
+export const dynamic = "force-dynamic";
+
+// `website` is a honeypot: hidden in the form, humans leave it empty, naive
+// bots fill it. Filled honeypot → pretend success, do nothing.
+const Schema = z.object({
+ email: z.string().email().max(254),
+ website: z.string().optional(),
+});
+
+// Same body whether the email was new, already pending, or already confirmed —
+// never reveals whether an address exists in the list.
+const GENERIC_OK = {
+ ok: true,
+ message: "Check your inbox — if this address isn't already confirmed, we've sent a confirmation link.",
+};
+
+export async function POST(req: NextRequest) {
+ const ip = clientIp(req);
+ const ipLimit = rateLimit(`newsletter:ip:${ip}`, 8, 10 * 60_000);
+ if (!ipLimit.allowed) return tooManyRequests(ipLimit);
+
+ const parsed = Schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "A valid email address is required." }, { status: 400 });
+ }
+ if (parsed.data.website) return NextResponse.json(GENERIC_OK);
+
+ const email = normalizeEmail(parsed.data.email);
+ const emailLimit = rateLimit(`newsletter:email:${email}`, 3, 15 * 60_000);
+ if (!emailLimit.allowed) return tooManyRequests(emailLimit);
+
+ const existing = await prisma.subscriber.findUnique({ where: { email } });
+
+ if (existing?.status === "CONFIRMED") {
+ // Already on the list — idempotent no-op, identical response.
+ return NextResponse.json(GENERIC_OK);
+ }
+
+ const confirmToken = newToken();
+ const unsubscribeToken = newToken();
+ await prisma.subscriber.upsert({
+ where: { email },
+ create: { email, status: "PENDING", confirmToken, unsubscribeToken },
+ update: { status: "PENDING", confirmToken, unsubscribeToken, unsubscribedAt: null },
+ });
+
+ const baseUrl = process.env.NEWSLETTER_BASE_URL || req.nextUrl.origin;
+ await sendNewsletterEmail(buildConfirmEmail(email, baseUrl, confirmToken));
+
+ return NextResponse.json(GENERIC_OK);
+}
diff --git a/src/app/api/newsletter/unsubscribe/route.ts b/src/app/api/newsletter/unsubscribe/route.ts
new file mode 100644
index 0000000..2b6a981
--- /dev/null
+++ b/src/app/api/newsletter/unsubscribe/route.ts
@@ -0,0 +1,33 @@
+import { NextRequest } from "next/server";
+import { prisma } from "@/lib/db";
+import { buildUnsubscribeNoticeEmail, sendNewsletterEmail } from "@/lib/newsletter";
+import { resultPage } from "../result-page";
+
+export const dynamic = "force-dynamic";
+
+// CAN-SPAM: single-click, no login, effective immediately.
+export async function GET(req: NextRequest) {
+ const token = req.nextUrl.searchParams.get("token") || "";
+ if (!token) {
+ return resultPage(400, "Missing link", "This unsubscribe link is incomplete. Please use the link from your email.");
+ }
+
+ const sub = await prisma.subscriber.findUnique({ where: { unsubscribeToken: token } });
+ if (!sub) {
+ return resultPage(404, "Link not recognized", "This unsubscribe link is invalid. If you keep receiving emails, reply to any digest and we'll remove you manually.");
+ }
+
+ if (sub.status !== "UNSUBSCRIBED") {
+ await prisma.subscriber.update({
+ where: { id: sub.id },
+ data: { status: "UNSUBSCRIBED", unsubscribedAt: new Date() },
+ });
+ await sendNewsletterEmail(buildUnsubscribeNoticeEmail(sub.email));
+ }
+
+ return resultPage(
+ 200,
+ "You're unsubscribed",
+ "You will receive no further digests. This took effect immediately. Changed your mind? You can re-subscribe any time at /newsletter.",
+ );
+}
diff --git a/src/app/b/[slug]/page.tsx b/src/app/b/[slug]/page.tsx
new file mode 100644
index 0000000..400a64c
--- /dev/null
+++ b/src/app/b/[slug]/page.tsx
@@ -0,0 +1,83 @@
+import { notFound } from "next/navigation";
+import { prisma } from "@/lib/db";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { OfferForm } from "@/components/offer-form";
+import { formatMoney, countdown } from "@/lib/utils";
+
+export const dynamic = "force-dynamic";
+
+// Public, compliant buyer-interest page. Contingent only — never represents
+// ownership of an item that has not yet been won at auction.
+export default async function BuyerPage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const page = await prisma.buyerInterestPage.findUnique({
+ where: { slug },
+ include: { listing: { include: { research: true } } },
+ });
+ if (!page || !page.published) notFound();
+
+ const l = page.listing;
+ // Sub-headline is the research summary ONLY — never fall back to
+ // l.description, which already renders in the Card below (avoids a
+ // double-description render when no summary exists).
+ const subHeadline = l.research?.summary ?? null;
+
+ return (
+ <main className="mx-auto max-w-2xl space-y-4 p-5">
+ {/* 1. Headline leads — legal badge moved down to where it's actionable */}
+ <h1 className="text-3xl font-semibold tracking-tight">{page.headline}</h1>
+
+ {/* 4. Urgency line — only when the listing's closingAt is set */}
+ {l.closingAt && (
+ <p className="text-sm font-medium text-warning">
+ Auction closes{" "}
+ {new Date(l.closingAt).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}{" "}
+ ({countdown(l.closingAt)})
+ </p>
+ )}
+
+ {/* 3. Sub-headline from research summary or listing description */}
+ {subHeadline && (
+ <p className="text-sm text-muted-foreground">{subHeadline}</p>
+ )}
+
+ {l.imageUrls[0] && (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img src={l.imageUrls[0]} alt={l.title} className="h-64 w-full rounded-lg border object-cover" />
+ )}
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">{l.title}</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 text-sm">
+ <p className="text-muted-foreground">{l.description}</p>
+ {page.estDelivered && (
+ <p>
+ Estimated delivered price:{" "}
+ <span className="font-semibold">{formatMoney(Number(page.estDelivered))}</span>
+ </p>
+ )}
+ <p className="rounded-md bg-muted p-3 text-xs text-muted-foreground">{page.disclaimer}</p>
+ </CardContent>
+ </Card>
+
+ {/* 2. Contingent badge relocated here — immediately above the form where it's actionable */}
+ <Badge variant="warning">Contingent interest — not yet owned</Badge>
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">Register interest (contingent & non-binding)</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <OfferForm listingId={l.id} />
+ </CardContent>
+ </Card>
+ </main>
+ );
+}
diff --git a/src/app/billing/success/page.tsx b/src/app/billing/success/page.tsx
new file mode 100644
index 0000000..8f42040
--- /dev/null
+++ b/src/app/billing/success/page.tsx
@@ -0,0 +1,44 @@
+import Link from "next/link";
+import { getCurrentUser } from "@/lib/current-user";
+import { prisma } from "@/lib/db";
+import { tierDef } from "@/lib/tiers";
+import type { Tier } from "@prisma/client";
+
+export const dynamic = "force-dynamic";
+
+// Post-checkout landing. In MOCK mode (no webhook) we upgrade the user here so
+// the flow is demoable end-to-end. In TEST mode the Stripe webhook is the real
+// source of truth; this page just confirms.
+export default async function BillingSuccess({
+ searchParams,
+}: {
+ searchParams: Promise<{ tier?: string; mock?: string; session?: string }>;
+}) {
+ const sp = await searchParams;
+ const tier = (sp.tier === "STANDARD" || sp.tier === "PREMIUM" ? sp.tier : "STANDARD") as Tier;
+ const user = await getCurrentUser();
+
+ let applied = false;
+ if (user && sp.mock === "1") {
+ // MOCK upgrade: no real charge occurred. Reflect it so the gate is demoable.
+ await prisma.user.update({ where: { id: user.sub }, data: { tier } });
+ applied = true;
+ }
+
+ const def = tierDef(tier);
+ return (
+ <main style={{ maxWidth: 560, margin: "80px auto", fontFamily: "system-ui", padding: "0 20px" }}>
+ <div style={{ fontSize: 40 }}>🎉</div>
+ <h1 style={{ margin: "8px 0" }}>You're on {def.label}</h1>
+ <p style={{ color: "#475569" }}>
+ {sp.mock === "1"
+ ? "(TEST/mock checkout — no real charge.) "
+ : ""}
+ {applied ? "Your account is upgraded — the money-math + hot-deal alerts are unlocked." : "Your subscription is confirming; refresh in a moment."}
+ </p>
+ <p style={{ marginTop: 24 }}>
+ <Link href="/" style={{ color: "#2563eb" }}>→ Open the dashboard</Link>
+ </p>
+ </main>
+ );
+}
diff --git a/src/app/deals/[date]/page.tsx b/src/app/deals/[date]/page.tsx
new file mode 100644
index 0000000..fb879a5
--- /dev/null
+++ b/src/app/deals/[date]/page.tsx
@@ -0,0 +1,196 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getDigestEditionsForDate, type DigestEdition, type SnapshotDeal } from "@/lib/digest-snapshot";
+import { publicBaseUrl } from "@/lib/site";
+
+export const dynamic = "force-dynamic";
+
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+const fmtDate = (iso: string) =>
+ new Date(`${iso}T12:00:00Z`).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+
+const confidenceLabel: Record<string, { text: string; color: string }> = {
+ HIGH: { text: "High confidence", color: "#16a34a" },
+ MEDIUM: { text: "Medium confidence", color: "#d97706" },
+ LOW: { text: "Low confidence", color: "#64748b" },
+};
+
+// The public teaser shows a conservative ROI BAND, not a point estimate —
+// roiConservative is already the haircut low band, so band = [~80% of it, it].
+function roiBand(d: SnapshotDeal): string {
+ const hi = Math.round(d.roiConservative * 100);
+ const lo = Math.max(0, Math.round(hi * 0.8));
+ return `${lo}–${hi}%${d.roiCapped ? " (capped)" : ""}`;
+}
+
+type Params = { date: string };
+
+export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
+ const { date } = await params;
+ if (!DATE_RE.test(date)) return {};
+ const editions = await getDigestEditionsForDate(date);
+ if (editions.length === 0) return {};
+
+ const base = publicBaseUrl();
+ const dealCount = editions.reduce((n, e) => n + e.dealCount, 0);
+ const top = editions.flatMap((e) => e.deals)[0];
+ const title = `Top-10 Gov-Surplus Deals — ${fmtDate(date)}`;
+ const description = top
+ ? `${dealCount} honesty-gated government-surplus deal${dealCount === 1 ? "" : "s"} on ${fmtDate(date)}, led by "${top.title}". Conservative, confidence-labeled numbers only.`
+ : `The ${fmtDate(date)} digest ran with no deals clearing the hot-deal gate — we publish nothing rather than pad the list.`;
+
+ return {
+ title: `${title} | GovArbitrage`,
+ description,
+ alternates: { canonical: `${base}/deals/${date}` },
+ openGraph: {
+ title,
+ description,
+ url: `${base}/deals/${date}`,
+ type: "article",
+ },
+ twitter: { card: "summary_large_image" },
+ };
+}
+
+function editionJsonLd(edition: DigestEdition, base: string) {
+ return {
+ "@context": "https://schema.org",
+ "@type": "ItemList",
+ name: `Top-10 Gov-Surplus Deals — ${fmtDate(edition.date)} (${edition.slot === "AM" ? "Morning" : "Evening"} edition)`,
+ url: `${base}/deals/${edition.date}`,
+ numberOfItems: edition.deals.length,
+ itemListOrder: "https://schema.org/ItemListOrderAscending",
+ itemListElement: edition.deals.map((d) => ({
+ "@type": "ListItem",
+ position: d.rank,
+ name: d.title,
+ url: `${base}/deals/${edition.date}`,
+ description: d.disclaimer || `Government-surplus deal ranked #${d.rank} in the ${edition.slot === "AM" ? "morning" : "evening"} edition. Conservative ROI: ${Math.round(d.roiConservative * 100)}%.`,
+ })),
+ };
+}
+
+function TeaserRow({ deal }: { deal: SnapshotDeal }) {
+ const where = [deal.locationCity, deal.locationState].filter(Boolean).join(", ");
+ const conf = confidenceLabel[deal.confidence] ?? confidenceLabel.LOW;
+ return (
+ <li style={{ border: "1px solid #e2e8f0", borderRadius: 10, padding: "14px 18px", marginBottom: 12 }}>
+ <div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
+ <span style={{ fontWeight: 700, color: "#94a3b8", fontSize: 15, flexShrink: 0 }}>#{deal.rank}</span>
+ <div style={{ minWidth: 0 }}>
+ <div style={{ fontWeight: 700, fontSize: 16 }}>{deal.title}</div>
+ <div style={{ color: "#64748b", fontSize: 13, marginTop: 2 }}>
+ {deal.source.replace(/_/g, " ")}
+ {where ? <> · {where}</> : null}
+ {" · "}
+ <span style={{ color: conf.color, fontWeight: 600 }}>{conf.text}</span>
+ </div>
+ <div style={{ fontSize: 14, marginTop: 8, color: "#334155" }}>
+ Conservative ROI band: <b>{roiBand(deal)}</b>
+ </div>
+ <div style={{ fontSize: 14, marginTop: 6, color: "#334155", display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "center" }}>
+ <span>
+ Rec. max bid:{" "}
+ <span aria-hidden style={{ filter: "blur(5px)", userSelect: "none" }}>$8,450</span>
+ </span>
+ <span>
+ Est. net profit:{" "}
+ <span aria-hidden style={{ filter: "blur(5px)", userSelect: "none" }}>$3,120</span>
+ </span>
+ <a href="/newsletter" style={{ color: "#2563eb", fontSize: 13, fontWeight: 600 }}>
+ Subscribe free to see the full math →
+ </a>
+ </div>
+ <div style={{ color: "#94a3b8", fontSize: 12, marginTop: 6 }}>{deal.disclaimer}</div>
+ </div>
+ </div>
+ </li>
+ );
+}
+
+export default async function DealsByDatePage({ params }: { params: Promise<Params> }) {
+ const { date } = await params;
+ if (!DATE_RE.test(date)) notFound();
+
+ const editions = await getDigestEditionsForDate(date);
+ if (editions.length === 0) notFound();
+
+ const base = publicBaseUrl();
+
+ return (
+ <main style={{ maxWidth: 720, margin: "56px auto", fontFamily: "system-ui", padding: "0 20px", color: "#0f172a" }}>
+ {editions.map((e) => (
+ <script
+ key={`ld-${e.slot}`}
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(editionJsonLd(e, base)) }}
+ />
+ ))}
+
+ <p style={{ fontSize: 13, marginBottom: 8 }}>
+ <Link href="/deals" style={{ color: "#64748b", textDecoration: "none" }}>← Deal archive</Link>
+ </p>
+ <header style={{ marginBottom: 24 }}>
+ <h1 style={{ fontSize: 30, margin: 0 }}>Top-10 Gov-Surplus Deals — {fmtDate(date)}</h1>
+ <p style={{ color: "#475569", fontSize: 15, lineHeight: 1.6 }}>
+ Frozen as published. Every figure is honesty-gated — confidence-discounted,
+ capped, and disclaimed. Exact recommended max bids and net-profit math go
+ to <Link href="/newsletter" style={{ color: "#2563eb" }}>free subscribers</Link>.
+ </p>
+ </header>
+
+ {editions.map((e) => (
+ <section key={e.slot} style={{ marginBottom: 30 }}>
+ <h2 style={{ fontSize: 20, margin: "0 0 4px" }}>
+ {e.slot === "AM" ? "Morning" : "Evening"} edition
+ <span style={{ color: "#94a3b8", fontWeight: 400, fontSize: 14 }}>
+ {" "}· {e.dealCount} deal{e.dealCount === 1 ? "" : "s"}
+ </span>
+ </h2>
+ {e.deals.length === 0 ? (
+ <p style={{ color: "#64748b", fontSize: 14, border: "1px dashed #cbd5e1", borderRadius: 10, padding: "18px" }}>
+ No deals cleared the hot-deal gate this run. We'd rather publish
+ nothing than pad the list with marginal lots.
+ </p>
+ ) : (
+ <ul style={{ listStyle: "none", padding: 0, margin: "12px 0 0" }}>
+ {e.deals.map((d) => (
+ <TeaserRow key={d.rank} deal={d} />
+ ))}
+ </ul>
+ )}
+ </section>
+ ))}
+
+ <div style={{ textAlign: "center", margin: "34px 0" }}>
+ <a
+ href="/newsletter"
+ style={{
+ display: "inline-block",
+ background: "#0f172a",
+ color: "#fff",
+ padding: "12px 26px",
+ borderRadius: 8,
+ fontSize: 15,
+ textDecoration: "none",
+ }}
+ >
+ Get the next Top-10 by email — subscribe free →
+ </a>
+ </div>
+
+ <p style={{ textAlign: "center", color: "#94a3b8", fontSize: 12, lineHeight: 1.6 }}>
+ Estimates are heuristic — always verify comps before bidding. Archive pages
+ are frozen at publish time; live auction state will have moved on.
+ </p>
+ </main>
+ );
+}
diff --git a/src/app/deals/page.tsx b/src/app/deals/page.tsx
new file mode 100644
index 0000000..13b1898
--- /dev/null
+++ b/src/app/deals/page.tsx
@@ -0,0 +1,104 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { listDigestEditions } from "@/lib/digest-snapshot";
+import { publicBaseUrl } from "@/lib/site";
+
+export const dynamic = "force-dynamic";
+
+const base = publicBaseUrl();
+
+export const metadata: Metadata = {
+ title: "Deal Archive — Top-10 Gov-Surplus Deals Digest | GovArbitrage",
+ description:
+ "Every edition of the twice-daily Top-10 government-surplus deals digest — honesty-gated, confidence-labeled, conservative numbers only.",
+ alternates: { canonical: `${base}/deals` },
+ openGraph: {
+ title: "GovArbitrage Deal Archive — Top-10 Gov-Surplus Deals",
+ description:
+ "Browse past editions of the twice-daily Top-10 government-surplus arbitrage digest. Every figure is confidence-discounted and capped, never inflated.",
+ url: `${base}/deals`,
+ type: "website",
+ },
+};
+
+const fmtDate = (iso: string) =>
+ new Date(`${iso}T12:00:00Z`).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+
+export default async function DealsArchivePage() {
+ const editions = await listDigestEditions(60);
+
+ return (
+ <main className="mx-auto max-w-2xl space-y-6 p-5 pt-14 text-foreground">
+ <header className="space-y-3 text-center">
+ <h1 className="text-3xl font-semibold tracking-tight">The Deal Archive</h1>
+ <p className="text-base text-muted-foreground leading-relaxed">
+ Every edition of our twice-daily Top-10 government-surplus deals digest,
+ frozen exactly as it went out. Every figure passes the Honesty Gate:
+ confidence-discounted, capped, and disclaimed — we publish the
+ conservative low band, never the hype number.
+ </p>
+ <Link
+ href="/newsletter"
+ className="inline-flex items-center justify-center rounded-md bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
+ >
+ Subscribe free — get the next edition by email →
+ </Link>
+ </header>
+
+ {editions.length === 0 ? (
+ <div className="rounded-xl border border-dashed p-10 text-center text-muted-foreground">
+ <p className="m-0 font-semibold text-foreground">First edition coming at launch.</p>
+ <p className="mt-2 text-sm">
+ The digest publishes twice daily (6am & 5pm PT) once we go live.{" "}
+ <Link href="/newsletter" className="text-primary hover:underline">Subscribe now</Link>{" "}
+ and you'll get edition #1.
+ </p>
+ </div>
+ ) : (
+ <ul className="space-y-3 p-0 list-none">
+ {editions.map((e) => (
+ <li
+ key={`${e.date}-${e.slot}`}
+ className="rounded-xl border bg-card text-card-foreground px-4 py-3"
+ >
+ <Link href={`/deals/${e.date}`} className="no-underline text-inherit block">
+ <div className="flex items-baseline justify-between gap-3">
+ <span className="font-bold text-base">
+ {fmtDate(e.date)}{" "}
+ <span className="text-muted-foreground font-medium">
+ · {e.slot === "AM" ? "Morning" : "Evening"} edition
+ </span>
+ </span>
+ <span className="text-muted-foreground text-xs whitespace-nowrap">
+ {e.dealCount} deal{e.dealCount === 1 ? "" : "s"}
+ </span>
+ </div>
+ <div className="text-muted-foreground text-sm mt-1">
+ {e.deals.length > 0 ? (
+ <>Top pick: {e.deals[0].title}</>
+ ) : (
+ <>No deals cleared the hot-deal gate this run — we'd rather send nothing than pad the list.</>
+ )}
+ </div>
+ </Link>
+ </li>
+ ))}
+ </ul>
+ )}
+
+ <p className="text-center text-muted-foreground text-xs leading-relaxed">
+ Archive editions show deal teasers. Exact recommended max bids and the
+ full cost math go to{" "}
+ <Link href="/newsletter" className="text-muted-foreground hover:text-foreground underline">
+ free subscribers
+ </Link>
+ . Estimates are heuristic — always verify comps before bidding.
+ </p>
+ </main>
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..532774f
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,72 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* Design tokens. Dark-first; .dark overrides flow through via @theme inline. */
+:root {
+ --background: hsl(0 0% 100%);
+ --foreground: hsl(222 47% 11%);
+ --card: hsl(0 0% 100%);
+ --card-foreground: hsl(222 47% 11%);
+ --muted: hsl(210 40% 96%);
+ --muted-foreground: hsl(215 16% 47%);
+ --border: hsl(214 32% 91%);
+ --primary: hsl(221 83% 53%);
+ --primary-foreground: hsl(0 0% 100%);
+ --accent: hsl(210 40% 94%);
+ --positive: hsl(142 71% 45%);
+ --negative: hsl(0 72% 51%);
+ --warning: hsl(38 92% 50%);
+}
+
+.dark {
+ --background: hsl(222 24% 8%);
+ --foreground: hsl(210 40% 96%);
+ --card: hsl(222 22% 11%);
+ --card-foreground: hsl(210 40% 96%);
+ --muted: hsl(217 24% 16%);
+ --muted-foreground: hsl(215 20% 65%);
+ --border: hsl(217 24% 20%);
+ --primary: hsl(217 91% 60%);
+ --primary-foreground: hsl(222 47% 11%);
+ --accent: hsl(217 24% 18%);
+ --positive: hsl(142 69% 58%);
+ --negative: hsl(0 84% 65%);
+ --warning: hsl(38 92% 60%);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-border: var(--border);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-accent: var(--accent);
+ --color-positive: var(--positive);
+ --color-negative: var(--negative);
+ --color-warning: var(--warning);
+}
+
+* {
+ border-color: var(--border);
+}
+
+body {
+ background: var(--background);
+ color: var(--foreground);
+ font-feature-settings: "tnum" 1;
+}
+
+/* Thin, dark scrollbars for the wide table. */
+::-webkit-scrollbar {
+ height: 10px;
+ width: 10px;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 6px;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..58ba321
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,28 @@
+import type { Metadata } from "next";
+import "./globals.css";
+import { publicBaseUrl } from "@/lib/site";
+
+export const metadata: Metadata = {
+ metadataBase: new URL(publicBaseUrl()),
+ title: {
+ default: "GovArbitrage — Government Surplus Arbitrage",
+ template: "%s | GovArbitrage",
+ },
+ description:
+ "Discover, research, value, and score government-surplus auction listings for resale arbitrage.",
+ openGraph: {
+ siteName: "GovArbitrage",
+ type: "website",
+ },
+ twitter: {
+ card: "summary_large_image",
+ },
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="en" className="dark">
+ <body className="min-h-screen antialiased">{children}</body>
+ </html>
+ );
+}
diff --git a/src/app/listings/[id]/page.tsx b/src/app/listings/[id]/page.tsx
new file mode 100644
index 0000000..010aaad
--- /dev/null
+++ b/src/app/listings/[id]/page.tsx
@@ -0,0 +1,419 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { getGatedListingDetail } from "@/lib/listing-detail";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { formatMoney, formatPercent, countdown } from "@/lib/utils";
+import { auctionNetworkLinks, boardLinks, internationalNetworkLinks } from "@/lib/marketplace-links";
+
+export const dynamic = "force-dynamic";
+
+const n = (d: unknown): number => (d == null ? 0 : Number(d));
+
+export default async function ListingDetail({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const { listing, gated } = await getGatedListingDetail(id);
+ if (!listing) notFound();
+
+ const r = listing.research;
+ const c = listing.costBreakdown;
+
+ const valuationRows: [string, number | null | undefined][] = [
+ ["New Retail", n(r?.newRetail)],
+ ["New Replacement", n(r?.newReplacement)],
+ ["Avg Retail", n(r?.avgRetail)],
+ ["Used Sold", n(r?.usedSoldPrice)],
+ ["Used Asking", n(r?.usedAskingPrice)],
+ ["Wholesale", n(r?.wholesaleValue)],
+ ["Liquidation", n(r?.liquidationValue)],
+ ["Sell Today", n(r?.sellTodayValue)],
+ ["7-Day Value", n(r?.value7Day)],
+ ["30-Day Value", n(r?.value30Day)],
+ ["90-Day Value", n(r?.value90Day)],
+ ["Expected Sale", n(r?.expectedSalePrice)],
+ ];
+
+ const costRows: [string, number][] = c
+ ? [
+ ["Winning Bid", n(c.winningBid)],
+ ["Buyer Premium", n(c.buyerPremium)],
+ ["Sales Tax", n(c.salesTax)],
+ ["Shipping", n(c.shipping)],
+ ["Freight", n(c.freight)],
+ ["Insurance", n(c.insurance)],
+ ["Packing", n(c.packing)],
+ ["Pickup Labor", n(c.pickupLabor)],
+ ["Testing", n(c.testing)],
+ ["Repairs", n(c.repairs)],
+ ["Certification", n(c.certification)],
+ ["Marketplace Fees", n(c.marketplaceFees)],
+ ["Payment Fees", n(c.paymentFees)],
+ ["Storage", n(c.storage)],
+ ["Photography", n(c.photography)],
+ ["Listing Labor", n(c.listingLabor)],
+ ]
+ : [];
+
+ return (
+ <main className="mx-auto max-w-6xl space-y-5 p-5">
+ <div className="flex items-center gap-3 text-sm">
+ <Link href="/" className="text-primary hover:underline">
+ ← Dashboard
+ </Link>
+ <Badge variant="primary">{listing.source.replace(/_/g, " ")}</Badge>
+ <span className="text-muted-foreground">#{listing.sourceAuctionId}</span>
+ {listing.sourceUrl && (
+ <a href={listing.sourceUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
+ View source ↗
+ </a>
+ )}
+ </div>
+
+ <h1 className="text-2xl font-semibold tracking-tight">{listing.title}</h1>
+
+ {/* Gallery */}
+ <div className="flex gap-3 overflow-x-auto">
+ {listing.imageUrls.length ? (
+ listing.imageUrls.map((u) => (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img key={u} src={u} alt={listing.title} className="h-48 w-72 flex-shrink-0 rounded-lg border object-cover" />
+ ))
+ ) : (
+ <div className="flex h-48 w-72 items-center justify-center rounded-lg border text-muted-foreground">
+ No images
+ </div>
+ )}
+ </div>
+
+ {/* Headline metrics */}
+ <div className="grid grid-cols-2 gap-3 md:grid-cols-5">
+ <Metric label="Current Bid" value={formatMoney(n(listing.currentBid))} />
+ <MetricOrGated label="Rec. Max Bid" value={formatMoney(n(c?.recommendedMaxBid))} gatedPlaceholder="$9,999" gated={gated} accent />
+ <MetricOrGated label="Expected Net" value={formatMoney(n(c?.expectedNetProfit))} gatedPlaceholder="$9,999" gated={gated} accent />
+ <MetricOrGated label="ROI" value={formatPercent(n(c?.roi))} gatedPlaceholder="$9,999" gated={gated} accent />
+ <Metric label="Closes" value={countdown(listing.closingAt)} />
+ </div>
+
+ {gated && (
+ <Card>
+ <CardContent className="flex flex-wrap items-center justify-between gap-3 py-4 text-sm">
+ <span>
+ The money math — recommended max bid, valuation ladder, full cost breakdown,
+ expected net profit, comparables, and buyer leads — is part of the paid tiers.
+ </span>
+ <Link href="/pricing" className="rounded-md bg-primary px-3 py-1.5 font-medium text-primary-foreground">
+ See plans →
+ </Link>
+ </CardContent>
+ </Card>
+ )}
+
+ {!gated && (
+ <div className="grid gap-5 md:grid-cols-2">
+ {/* Valuation ladder */}
+ <Card>
+ <CardHeader>
+ <CardTitle>Valuation</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-1">
+ {valuationRows.map(([label, val]) => (
+ <Row key={label} label={label} value={formatMoney(val)} />
+ ))}
+ <Row label="Probability of Sale" value={formatPercent(n(r?.probabilityOfSale))} />
+ <Row label="Days Until Sold" value={`${r?.daysUntilSold ?? "—"}`} />
+ <Row label="Confidence" value={`${Math.round(n(r?.confidenceScore))}/100`} />
+ </CardContent>
+ </Card>
+
+ {/* Cost breakdown */}
+ <Card>
+ <CardHeader>
+ <CardTitle>Cost Breakdown & Profit</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-1">
+ {costRows.map(([label, val]) => (
+ <Row key={label} label={label} value={formatMoney(val)} />
+ ))}
+ <div className="my-2 border-t" />
+ <Row label="Total Investment" value={formatMoney(n(c?.totalInvestment))} strong />
+ <Row label="Expected Returns" value={formatMoney(n(c?.expectedReturns))} strong />
+ <Row label="Expected Net Profit" value={formatMoney(n(c?.expectedNetProfit))} strong />
+ <Row label="ROI" value={formatPercent(n(c?.roi))} strong />
+ <Row label="Annualized" value={formatPercent(n(c?.annualizedReturn))} />
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Score explanations */}
+ <Card>
+ <CardHeader>
+ <CardTitle>Scoring — why each profile scored this listing</CardTitle>
+ </CardHeader>
+ <CardContent className="grid gap-3 md:grid-cols-3">
+ {listing.scores.map((s) => (
+ <div key={s.id} className="rounded-lg border p-3">
+ <div className="mb-1 flex items-center justify-between">
+ <span className="text-sm font-medium">{s.profile.replace(/_/g, " ")}</span>
+ <Badge variant={s.value >= 70 ? "positive" : s.value >= 50 ? "warning" : "outline"}>
+ {Math.round(s.value)}
+ </Badge>
+ </div>
+ <div className="mt-1.5 h-1 w-full rounded-full bg-muted">
+ <div
+ className="h-1 rounded-full"
+ style={{
+ width: `${Math.max(0, Math.min(100, s.value))}%`,
+ background:
+ s.value >= 70
+ ? "var(--positive)"
+ : s.value >= 50
+ ? "var(--warning)"
+ : "var(--negative)",
+ }}
+ />
+ </div>
+ <p className="text-xs text-muted-foreground">{s.explanation}</p>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+
+ <div className="grid gap-5 md:grid-cols-2">
+ {/* Comparables */}
+ <Card>
+ <CardHeader>
+ <CardTitle>Comparable Sales & Listings</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-1">
+ {listing.comparables.length === 0 && <p className="text-sm text-muted-foreground">None.</p>}
+ {listing.comparables.map((cp) => (
+ <div key={cp.id} className="flex items-center justify-between text-sm">
+ <span className="flex items-center gap-2">
+ <Badge variant="outline">{cp.kind}</Badge>
+ <span className="truncate">{cp.title}</span>
+ </span>
+ <span className="tabular-nums">{formatMoney(n(cp.price))}</span>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+
+ {/* Terms + risk */}
+ <Card>
+ <CardHeader>
+ <CardTitle>Auction Terms & Risk</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 text-sm">
+ <div>
+ <span className="text-muted-foreground">Condition: </span>
+ {listing.condition.replace(/_/g, " ")} · Qty {listing.quantity} · {n(listing.weightLbs)} lb
+ </div>
+ <div>
+ <span className="text-muted-foreground">Location: </span>
+ {[listing.locationCity, listing.locationState, listing.locationZip].filter(Boolean).join(", ") || "—"}
+ </div>
+ <p className="text-muted-foreground">{listing.auctionTerms}</p>
+ {r?.summary && <p className="rounded-md bg-muted p-2 text-xs">{r.summary}</p>}
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* Buyer workflow (compliant) */}
+ <Card>
+ <CardHeader>
+ <CardTitle>Buyer Interest (contingent — no ownership represented pre-win)</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 text-sm">
+ {listing.buyerPage ? (
+ <div>
+ Interest page: <span className="font-medium">/{listing.buyerPage.slug}</span> ·{" "}
+ {listing.buyerPage.published ? <Badge variant="positive">Published</Badge> : <Badge variant="outline">Draft</Badge>} ·
+ est. delivered {formatMoney(n(listing.buyerPage.estDelivered))}
+ </div>
+ ) : (
+ <p className="text-muted-foreground">No buyer-interest page yet.</p>
+ )}
+ {listing.buyerLeads.map((l) => (
+ <div key={l.id} className="flex items-center justify-between rounded-md border p-2">
+ <span>
+ {l.name} — {l.email} {l.contingent && <Badge variant="outline">contingent</Badge>}
+ </span>
+ <span className="tabular-nums">{formatMoney(n(l.offer))}</span>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+
+ {/* CRE agents — deep-link to /agents pre-filled with this listing's location */}
+ {(listing.locationCity || listing.locationState || listing.locationZip) && (
+ <Card>
+ <CardHeader>
+ <CardTitle>Find Commercial Real Estate Agents Near This Property</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2 text-sm">
+ <p className="text-muted-foreground">
+ Buyer / acquisition brokers and leasing agents sourced live from Google Places for{" "}
+ {[listing.locationCity, listing.locationState].filter(Boolean).join(", ") || listing.locationZip}.
+ </p>
+ <div className="flex flex-wrap gap-2">
+ <Link
+ href={`/agents?${new URLSearchParams({
+ ...(listing.locationCity ? { city: listing.locationCity } : {}),
+ ...(listing.locationState ? { state: listing.locationState } : {}),
+ ...(listing.locationZip ? { zip: listing.locationZip } : {}),
+ type: "both",
+ }).toString()}`}
+ className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
+ >
+ Find agents (buyer + leasing) →
+ </Link>
+ <Link
+ href={`/agents?${new URLSearchParams({
+ ...(listing.locationCity ? { city: listing.locationCity } : {}),
+ ...(listing.locationState ? { state: listing.locationState } : {}),
+ ...(listing.locationZip ? { zip: listing.locationZip } : {}),
+ type: "buyer",
+ }).toString()}`}
+ className="rounded-md border px-3 py-1.5 text-sm text-primary hover:bg-accent"
+ >
+ Buyer / acquisition only →
+ </Link>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* Compare & source elsewhere */}
+ <div className="grid gap-5 md:grid-cols-2">
+ <Card>
+ <CardHeader>
+ <CardTitle>Actual offers on other networks</CardTitle>
+ </CardHeader>
+ <CardContent className="flex flex-wrap gap-2">
+ {auctionNetworkLinks(listing).map((c) => (
+ <a
+ key={c.url}
+ href={c.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="rounded-md border px-2 py-1 text-sm text-primary hover:bg-accent"
+ >
+ {c.label} ↗
+ </a>
+ ))}
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader>
+ <CardTitle>Goods-needed boards & communities</CardTitle>
+ </CardHeader>
+ <CardContent className="flex flex-wrap gap-2">
+ {boardLinks(listing).map((c) => (
+ <a
+ key={c.url}
+ href={c.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="rounded-md border px-2 py-1 text-sm text-primary hover:bg-accent"
+ >
+ {c.label} ↗
+ </a>
+ ))}
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* International offers */}
+ <Card>
+ <CardHeader>
+ <CardTitle>International offers — Europe · Japan · HK · Australia · China</CardTitle>
+ </CardHeader>
+ <CardContent className="grid gap-3 md:grid-cols-5">
+ {internationalNetworkLinks(listing).map((r) => (
+ <div key={r.region}>
+ <div className="mb-1 text-xs font-semibold text-muted-foreground">{r.region}</div>
+ <div className="flex flex-col gap-1">
+ {r.links.map((c) => (
+ <a
+ key={c.url}
+ href={c.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-sm text-primary hover:underline"
+ >
+ {c.label} ↗
+ </a>
+ ))}
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+
+ {/* History */}
+ <Card>
+ <CardHeader>
+ <CardTitle>History</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-1 text-xs text-muted-foreground">
+ {listing.events.map((e) => (
+ <div key={e.id} className="flex justify-between">
+ <span>{e.message}</span>
+ <span>{new Date(e.createdAt).toLocaleString()}</span>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ </main>
+ );
+}
+
+function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
+ return (
+ <Card>
+ <CardContent className="p-3">
+ <div className="text-xs text-muted-foreground">{label}</div>
+ <div className={`text-lg font-semibold tabular-nums ${accent ? "text-primary" : ""}`}>{value}</div>
+ </CardContent>
+ </Card>
+ );
+}
+
+function MetricOrGated({
+ label,
+ value,
+ gatedPlaceholder,
+ gated,
+ accent,
+}: {
+ label: string;
+ value: string;
+ gatedPlaceholder: string;
+ gated: boolean;
+ accent?: boolean;
+}) {
+ return (
+ <Card>
+ <CardContent className="p-3">
+ <div className="text-xs text-muted-foreground">{label}</div>
+ <div className={`text-lg font-semibold tabular-nums ${accent ? "text-primary" : ""}`}>
+ {gated ? (
+ <span className="blur-sm select-none">{gatedPlaceholder}</span>
+ ) : (
+ value
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
+
+function Row({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
+ return (
+ <div className={`flex justify-between text-sm ${strong ? "font-semibold" : ""}`}>
+ <span className="text-muted-foreground">{label}</span>
+ <span className="tabular-nums">{value}</span>
+ </div>
+ );
+}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..e1c7ca8
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useState, Suspense } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+function LoginForm() {
+ const router = useRouter();
+ const search = useSearchParams();
+ const next = search.get("next") || "/";
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ setError("");
+ try {
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ });
+ if (res.ok) {
+ router.push(next);
+ router.refresh();
+ } else {
+ const d = await res.json().catch(() => ({}));
+ setError(d.error || "Login failed");
+ }
+ } catch {
+ setError("Network error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <Card className="w-full max-w-sm">
+ <CardHeader>
+ <CardTitle className="text-foreground text-lg">
+ Gov<span className="text-primary">Arbitrage</span> — Sign in
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={submit} className="space-y-3">
+ <Input type="text" autoCapitalize="none" autoCorrect="off" placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} required autoFocus />
+ <Input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required />
+ {error && <p className="text-sm text-[var(--negative)]">{error}</p>}
+ <Button type="submit" className="w-full" disabled={loading}>
+ {loading ? "Signing in…" : "Sign in"}
+ </Button>
+ </form>
+ </CardContent>
+ </Card>
+ );
+}
+
+export default function LoginPage() {
+ return (
+ <main className="flex min-h-screen items-center justify-center p-5">
+ <Suspense fallback={null}>
+ <LoginForm />
+ </Suspense>
+ </main>
+ );
+}
diff --git a/src/app/newsletter/SubscribeForm.tsx b/src/app/newsletter/SubscribeForm.tsx
new file mode 100644
index 0000000..0b0e869
--- /dev/null
+++ b/src/app/newsletter/SubscribeForm.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState } from "react";
+
+type State = { phase: "idle" | "busy" | "done" | "error"; message?: string };
+
+export default function SubscribeForm() {
+ const [email, setEmail] = useState("");
+ const [website, setWebsite] = useState(""); // honeypot — humans never see it
+ const [state, setState] = useState<State>({ phase: "idle" });
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setState({ phase: "busy" });
+ try {
+ const res = await fetch("/api/newsletter/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, website }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setState({ phase: "error", message: data.error || "Something went wrong. Please try again." });
+ return;
+ }
+ setState({ phase: "done", message: data.message });
+ } catch {
+ setState({ phase: "error", message: "Network error. Please try again." });
+ }
+ }
+
+ if (state.phase === "done") {
+ return (
+ <div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 12, padding: "22px 24px", textAlign: "center" }}>
+ <div style={{ fontSize: 20, fontWeight: 700, color: "#166534", marginBottom: 6 }}>Almost there — confirm your email</div>
+ <p style={{ color: "#15803d", fontSize: 14, margin: 0 }}>{state.message}</p>
+ </div>
+ );
+ }
+
+ return (
+ <form onSubmit={submit} style={{ display: "flex", gap: 10, justifyContent: "center", flexWrap: "wrap" }}>
+ <input
+ type="email"
+ required
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ placeholder="you@example.com"
+ aria-label="Email address"
+ style={{ flex: "1 1 260px", maxWidth: 340, padding: "11px 14px", fontSize: 15, border: "1px solid #cbd5e1", borderRadius: 10, background: "#fff", color: "#0f172a" }}
+ />
+ {/* Honeypot: visually hidden, tab-skipped. Bots that fill it are dropped. */}
+ <input
+ type="text"
+ name="website"
+ value={website}
+ onChange={(e) => setWebsite(e.target.value)}
+ tabIndex={-1}
+ autoComplete="off"
+ aria-hidden="true"
+ style={{ position: "absolute", left: -9999, width: 1, height: 1, opacity: 0 }}
+ />
+ <button
+ type="submit"
+ disabled={state.phase === "busy"}
+ style={{ padding: "11px 22px", fontSize: 15, fontWeight: 700, background: "#111827", color: "#fff", border: "none", borderRadius: 10, cursor: state.phase === "busy" ? "wait" : "pointer" }}
+ >
+ {state.phase === "busy" ? "Subscribing…" : "Get the digest"}
+ </button>
+ {state.phase === "error" && (
+ <div style={{ flexBasis: "100%", textAlign: "center", color: "#b91c1c", fontSize: 13 }}>{state.message}</div>
+ )}
+ </form>
+ );
+}
diff --git a/src/app/newsletter/page.tsx b/src/app/newsletter/page.tsx
new file mode 100644
index 0000000..fd8737a
--- /dev/null
+++ b/src/app/newsletter/page.tsx
@@ -0,0 +1,64 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import SubscribeForm from "./SubscribeForm";
+
+export const dynamic = "force-dynamic";
+
+const TITLE = "Deal Digest Newsletter";
+const DESC = "Twice-daily Top-10 government-surplus arbitrage deals, honesty-gated and confidence-labeled. Free, double opt-in, one-click unsubscribe.";
+
+export const metadata: Metadata = {
+ title: TITLE,
+ description: DESC,
+ alternates: { canonical: "/newsletter" },
+ openGraph: {
+ title: TITLE,
+ description: DESC,
+ url: "/newsletter",
+ type: "website",
+ },
+ twitter: { card: "summary_large_image" },
+};
+
+export default function NewsletterPage() {
+ return (
+ <main className="mx-auto max-w-2xl space-y-5 p-5 pt-14 text-foreground">
+ <div className="text-center space-y-2">
+ <h1 className="text-3xl font-semibold tracking-tight">The Top-10 Deals Digest</h1>
+ <p className="text-base text-muted-foreground leading-relaxed">
+ Twice a day (6am & 5pm PT), the ten best government-surplus arbitrage
+ opportunities across 8 auction networks — de-duped, scored, and
+ honesty-gated: every figure is confidence-discounted and capped, never
+ inflated. Free, double opt-in: confirm by email and the next digest is yours.
+ </p>
+ </div>
+
+ <ul className="list-none p-0 space-y-1 text-sm leading-loose text-foreground">
+ {[
+ "Top-10 opportunities ranked by conservative expected net profit",
+ "Recommended max bid with all-in cost math (premium, tax, freight, fees)",
+ "Confidence labels on every estimate — verify comps before bidding",
+ "One-click unsubscribe in every email, effective immediately",
+ ].map((f) => (
+ <li key={f}>
+ <span className="text-positive mr-2">✓</span>{f}
+ </li>
+ ))}
+ </ul>
+
+ <SubscribeForm />
+
+ <p className="text-center text-sm">
+ Want a preview first?{" "}
+ <Link href="/deals" className="text-primary hover:underline">Browse the deal archive →</Link>
+ </p>
+
+ <p className="text-center text-muted-foreground text-xs leading-relaxed">
+ Double opt-in: we only email you after you click the confirmation link.
+ Every digest carries a single-click unsubscribe link — no login, no questions,
+ effective immediately — plus our mailing address, per CAN-SPAM. We never
+ share or sell your address. Estimates are heuristic; always verify before bidding.
+ </p>
+ </main>
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..a870664
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,55 @@
+import { getDashboardStats } from "@/lib/dashboard";
+import { DashboardCards } from "@/components/dashboard-cards";
+import { ListingsTable } from "@/components/listings-table";
+import { LogoutButton } from "@/components/logout-button";
+import { getCurrentUser } from "@/lib/current-user";
+
+export const dynamic = "force-dynamic";
+
+export default async function DashboardPage() {
+ const [stats, user] = await Promise.all([getDashboardStats(), getCurrentUser()]);
+
+ return (
+ <main className="mx-auto max-w-[1800px] space-y-5 p-5">
+ <header className="flex items-center justify-between">
+ <div>
+ <h1 className="text-xl font-semibold tracking-tight">
+ Gov<span className="text-primary">Arbitrage</span>
+ </h1>
+ <p className="text-sm text-muted-foreground">
+ Government-surplus auction arbitrage — research, valuation, scoring & drop-ship feasibility.
+ </p>
+ </div>
+ <nav className="flex items-center gap-4 text-sm">
+ <a href="/agent-ops" className="text-primary underline-offset-4 hover:underline">
+ Agent ops →
+ </a>
+ <a href="/reports" className="text-primary underline-offset-4 hover:underline">
+ Reports →
+ </a>
+ <a href="/selling-avenues" className="text-primary underline-offset-4 hover:underline">
+ Selling avenues →
+ </a>
+ <a href="/agents" className="text-primary underline-offset-4 hover:underline">
+ Commercial agents →
+ </a>
+ <a href="/newsletter" className="text-primary underline-offset-4 hover:underline">
+ Newsletter →
+ </a>
+ <a href="/deals" className="text-primary underline-offset-4 hover:underline">
+ Deal archive →
+ </a>
+ {user && (
+ <span className="flex items-center gap-2 text-muted-foreground">
+ {user.email} · {user.role}
+ <LogoutButton />
+ </span>
+ )}
+ </nav>
+ </header>
+
+ <DashboardCards stats={stats} />
+ <ListingsTable />
+ </main>
+ );
+}
diff --git a/src/app/pricing/UpgradeButton.tsx b/src/app/pricing/UpgradeButton.tsx
new file mode 100644
index 0000000..fa7231d
--- /dev/null
+++ b/src/app/pricing/UpgradeButton.tsx
@@ -0,0 +1,56 @@
+"use client";
+import { useState } from "react";
+
+export default function UpgradeButton({
+ tier,
+ label,
+ current,
+}: {
+ tier: "STANDARD" | "PREMIUM";
+ label: string;
+ current: boolean;
+}) {
+ const [loading, setLoading] = useState(false);
+ const [err, setErr] = useState("");
+
+ if (current) {
+ return (
+ <div style={{ padding: "10px 0", fontWeight: 700, color: "#16a34a" }}>✓ Your plan</div>
+ );
+ }
+
+ async function go() {
+ setLoading(true);
+ setErr("");
+ try {
+ const res = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tier }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || "checkout failed");
+ window.location.href = data.url;
+ } catch (e) {
+ setErr((e as Error).message);
+ setLoading(false);
+ }
+ }
+
+ return (
+ <div>
+ <button
+ onClick={go}
+ disabled={loading}
+ style={{
+ width: "100%", padding: "11px 0", borderRadius: 8, border: "none",
+ background: "#111827", color: "#fff", fontWeight: 700, cursor: "pointer",
+ opacity: loading ? 0.6 : 1,
+ }}
+ >
+ {loading ? "Redirecting…" : `Upgrade to ${label}`}
+ </button>
+ {err && <div style={{ color: "#b91c1c", fontSize: 12, marginTop: 6 }}>{err}</div>}
+ </div>
+ );
+}
diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx
new file mode 100644
index 0000000..62008dd
--- /dev/null
+++ b/src/app/pricing/page.tsx
@@ -0,0 +1,118 @@
+import type { Metadata } from "next";
+import { TIERS, TIER_ORDER } from "@/lib/tiers";
+import { getCurrentTier } from "@/lib/current-user";
+import { STRIPE_MODE } from "@/lib/billing";
+import UpgradeButton from "./UpgradeButton";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = {
+ title: "Pricing",
+ description:
+ "GovArbitrage plans: Free (delayed deals), Standard ($12/mo — real-time + hot-deal alerts), and Premium ($29/mo — unlimited, confidence bands, priority alerts). Cancel anytime.",
+ alternates: { canonical: "/pricing" },
+ openGraph: {
+ title: "Pricing — GovArbitrage",
+ description:
+ "GovArbitrage plans: Free (delayed deals), Standard ($12/mo — real-time + hot-deal alerts), and Premium ($29/mo — unlimited, confidence bands, priority alerts). Cancel anytime.",
+ url: "/pricing",
+ type: "website",
+ },
+};
+
+const pricingJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "ItemList",
+ name: "GovArbitrage Pricing Plans",
+ itemListElement: TIER_ORDER.map((key, idx) => {
+ const t = TIERS[key];
+ return {
+ "@type": "ListItem",
+ position: idx + 1,
+ item: {
+ "@type": "Offer",
+ name: t.label,
+ description: t.blurb,
+ price: t.priceUsd.toString(),
+ priceCurrency: "USD",
+ eligibleDuration: {
+ "@type": "QuantitativeValue",
+ value: 1,
+ unitCode: "MON",
+ },
+ },
+ };
+ }),
+};
+
+export default async function PricingPage() {
+ const current = await getCurrentTier();
+
+ return (
+ <main className="mx-auto max-w-4xl space-y-6 p-5 pt-14 text-foreground">
+ <script
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(pricingJsonLd) }}
+ />
+ <div className="text-center space-y-2">
+ <h1 className="text-3xl font-semibold tracking-tight">Find the deals before everyone else</h1>
+ <p className="text-base text-muted-foreground">
+ 8 government-surplus auction networks, de-duped and scored — with honest,
+ confidence-labeled max-bid math. Cancel anytime.
+ </p>
+ {STRIPE_MODE !== "test" && (
+ <span className="inline-block bg-warning/20 text-warning text-xs px-3 py-1 rounded-full font-medium">
+ TEST MODE — no real charges (checkout is simulated)
+ </span>
+ )}
+ </div>
+
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3 mt-7">
+ {TIER_ORDER.map((key) => {
+ const t = TIERS[key];
+ const isCurrent = current === key;
+ const featured = key === "STANDARD";
+ return (
+ <div
+ key={key}
+ className={`relative rounded-xl bg-card text-card-foreground p-5 ${
+ featured ? "border-2 border-foreground" : "border"
+ }`}
+ >
+ {featured && (
+ <div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-foreground text-background text-[11px] font-bold px-3 py-0.5 rounded-full">
+ MOST POPULAR
+ </div>
+ )}
+ <div className="font-bold text-lg">{t.label}</div>
+ <div className="my-1.5">
+ <span className="text-4xl font-extrabold">${t.priceUsd}</span>
+ <span className="text-muted-foreground">{t.priceUsd > 0 ? "/mo" : ""}</span>
+ </div>
+ <div className="text-muted-foreground text-sm min-h-9">{t.blurb}</div>
+ <ul className="list-none p-0 my-3.5 space-y-1 text-sm leading-relaxed">
+ {t.features.map((f) => (
+ <li key={f}>
+ <span className="text-positive mr-1.5">✓</span>{f}
+ </li>
+ ))}
+ </ul>
+ {t.priceUsd === 0 ? (
+ <div className={`py-2.5 font-bold ${isCurrent ? "text-positive" : "text-muted-foreground"}`}>
+ {isCurrent ? "✓ Your plan" : "Default plan"}
+ </div>
+ ) : (
+ <UpgradeButton tier={key as "STANDARD" | "PREMIUM"} label={t.label} current={isCurrent} />
+ )}
+ </div>
+ );
+ })}
+ </div>
+
+ <p className="text-center text-muted-foreground text-xs leading-relaxed">
+ Estimates are heuristic and confidence-labeled — always verify comps before bidding.
+ Prices shown are introductory and may change.
+ </p>
+ </main>
+ );
+}
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
new file mode 100644
index 0000000..67ea499
--- /dev/null
+++ b/src/app/privacy/page.tsx
@@ -0,0 +1,129 @@
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Privacy Policy",
+ description:
+ "How GovArbitrage handles data across the website and the iOS app.",
+};
+
+const UPDATED = "August 6, 2026";
+
+export default function PrivacyPolicyPage() {
+ return (
+ <main className="mx-auto max-w-3xl px-6 py-16 text-neutral-200">
+ <h1 className="text-3xl font-bold tracking-tight text-white">Privacy Policy</h1>
+ <p className="mt-2 text-sm text-neutral-400">Last updated: {UPDATED}</p>
+
+ <p className="mt-8 leading-relaxed">
+ GovArbitrage aggregates public government auction listings and helps you find
+ underpriced opportunities. This policy explains what data we handle across two
+ surfaces — the <strong>website</strong> at auctions.agentabrams.com and the{" "}
+ <strong>GovArbitrage iOS app</strong> — and how we handle it.
+ </p>
+
+ <Section title="What the iOS app collects">
+ <p>
+ The GovArbitrage iOS app does <strong>not</strong> track you and contains{" "}
+ <strong>no advertising</strong>. It links no analytics or advertising SDKs. By
+ default it stores only the API server address and an optional access credential you
+ enter in Settings; these are held encrypted on your device (via the system keychain)
+ and are sent only to the server you configure.
+ </p>
+ <p className="mt-4">
+ <strong>Optional Sign in with Apple.</strong> The app works fully without an account.
+ If you choose to sign in with Apple, we create a lightweight account and store your
+ Apple user identifier and — only if you share them — your name and email address.
+ These are used <strong>solely to authenticate you and link your account</strong>; they
+ are never used for tracking or advertising and are never sold. You can sign out at any
+ time in Settings, and you can request deletion of your account and its data by
+ contacting us (see below).
+ </p>
+ </Section>
+
+ <Section title="What the website collects">
+ <ul className="list-disc space-y-2 pl-5">
+ <li>
+ <strong>Account information.</strong> If you create an account or subscribe, we
+ store the email address and account details needed to provide the service.
+ </li>
+ <li>
+ <strong>Payment information.</strong> Paid subscriptions are processed by our
+ payment provider (Stripe). We do not store your full card number; Stripe handles
+ payment data under its own privacy policy.
+ </li>
+ <li>
+ <strong>Usage and log data.</strong> Like most websites, our servers record
+ standard technical information (IP address, browser type, pages requested, and
+ timestamps) to operate, secure, and improve the service.
+ </li>
+ <li>
+ <strong>Cookies.</strong> We use strictly necessary cookies to keep you signed in
+ and maintain your session. We do not sell your data.
+ </li>
+ </ul>
+ </Section>
+
+ <Section title="Auction data">
+ <p>
+ The auction listings shown in GovArbitrage are sourced from public government
+ auction records and agency portals. That information is public and is not personal
+ data about you.
+ </p>
+ </Section>
+
+ <Section title="How we use data">
+ <p>
+ We use the information above only to provide, secure, maintain, and improve the
+ service, to process subscriptions, and to communicate with you about your account.
+ We do not sell personal information, and we do not use your data for third-party
+ advertising.
+ </p>
+ </Section>
+
+ <Section title="Data retention & your choices">
+ <p>
+ We retain account data for as long as your account is active. You may request access
+ to, correction of, or deletion of your account data at any time by contacting us at
+ the address below; we will delete your account and associated personal data on
+ request, subject to any legal retention obligations.
+ </p>
+ </Section>
+
+ <Section title="Children">
+ <p>
+ GovArbitrage is not directed to children under 13, and we do not knowingly collect
+ personal information from them.
+ </p>
+ </Section>
+
+ <Section title="Changes to this policy">
+ <p>
+ We may update this policy from time to time. Material changes will be reflected by
+ the “Last updated” date above.
+ </p>
+ </Section>
+
+ <Section title="Contact">
+ <p>
+ Questions about this policy or your data? Contact us at{" "}
+ <a
+ className="text-blue-400 underline"
+ href="mailto:privacy@auctions.agentabrams.com"
+ >
+ privacy@auctions.agentabrams.com
+ </a>
+ .
+ </p>
+ </Section>
+ </main>
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+ <section className="mt-10">
+ <h2 className="text-xl font-semibold text-white">{title}</h2>
+ <div className="mt-3 leading-relaxed text-neutral-300">{children}</div>
+ </section>
+ );
+}
diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx
new file mode 100644
index 0000000..f84e447
--- /dev/null
+++ b/src/app/reports/page.tsx
@@ -0,0 +1,110 @@
+import Link from "next/link";
+import { getReports, type GroupPerf } from "@/lib/reports";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { formatMoney, formatPercent } from "@/lib/utils";
+
+export const dynamic = "force-dynamic";
+export const metadata = { title: "Reports — GovArbitrage" };
+
+function PerfTable({ title, rows, label }: { title: string; rows: GroupPerf[]; label: string }) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">{title}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <table className="w-full text-sm">
+ <thead>
+ <tr className="text-left text-xs text-muted-foreground">
+ <th className="py-1">{label}</th>
+ <th className="py-1 text-right">Count</th>
+ <th className="py-1 text-right">Avg ROI</th>
+ <th className="py-1 text-right">Total Net</th>
+ </tr>
+ </thead>
+ <tbody>
+ {rows.map((r) => (
+ <tr key={r.key} className="border-t">
+ <td className="py-1">{r.key.replace(/_/g, " ")}</td>
+ <td className="py-1 text-right tabular-nums">{r.count}</td>
+ <td className="py-1 text-right tabular-nums">{formatPercent(r.avgRoi)}</td>
+ <td className="py-1 text-right tabular-nums">{formatMoney(r.totalNet)}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </CardContent>
+ </Card>
+ );
+}
+
+export default async function ReportsPage() {
+ const rep = await getReports();
+ const cards = [
+ { title: "Total Expected Profit", value: formatMoney(rep.totalExpectedProfit) },
+ { title: "Average ROI", value: formatPercent(rep.avgRoi) },
+ { title: "Cash Required", value: formatMoney(rep.cashRequired) },
+ { title: "Auction Success Rate", value: formatPercent(rep.auctionSuccessRate) },
+ ];
+
+ return (
+ <main className="mx-auto max-w-5xl space-y-5 p-5">
+ <div className="text-sm">
+ <Link href="/" className="text-primary hover:underline">
+ ← Dashboard
+ </Link>
+ </div>
+ <h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
+
+ <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
+ {cards.map((c) => (
+ <Card key={c.title}>
+ <CardHeader className="pb-1">
+ <CardTitle>{c.title}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-semibold tabular-nums">{c.value}</div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+
+ <div className="grid gap-4 md:grid-cols-2">
+ <PerfTable title="Category Performance" rows={rep.categoryPerformance} label="Category" />
+ <PerfTable title="Marketplace / Source Performance" rows={rep.sourcePerformance} label="Source" />
+ </div>
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">Inventory by Research Status</CardTitle>
+ </CardHeader>
+ <CardContent className="flex flex-wrap gap-4 text-sm">
+ {rep.inventoryByStatus.map((s) => (
+ <div key={s.status}>
+ <span className="text-muted-foreground">{s.status}: </span>
+ <span className="font-semibold tabular-nums">{s.count}</span>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+
+ {rep.estimatedVsActual.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">Estimated vs Actual (won lots)</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-1 text-sm">
+ {rep.estimatedVsActual.map((e) => (
+ <div key={e.title} className="flex justify-between">
+ <span className="truncate">{e.title}</span>
+ <span className="tabular-nums">
+ {formatMoney(e.estimated)} → {formatMoney(e.actual)}
+ </span>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )}
+ </main>
+ );
+}
diff --git a/src/app/robots.ts b/src/app/robots.ts
new file mode 100644
index 0000000..a34a891
--- /dev/null
+++ b/src/app/robots.ts
@@ -0,0 +1,25 @@
+import type { MetadataRoute } from "next";
+import { publicBaseUrl } from "@/lib/site";
+
+export default function robots(): MetadataRoute.Robots {
+ const base = publicBaseUrl();
+ return {
+ rules: [
+ {
+ userAgent: "*",
+ allow: ["/newsletter", "/pricing", "/deals", "/selling-avenues", "/b/"],
+ // App/private surfaces: auth-walled anyway, but keep crawlers out of
+ // redirects and per-listing buyer pages entirely.
+ disallow: [
+ "/api/",
+ "/listings",
+ "/reports",
+ "/billing",
+ "/credentials",
+ "/login",
+ ],
+ },
+ ],
+ sitemap: `${base}/sitemap.xml`,
+ };
+}
diff --git a/src/app/selling-avenues/page.tsx b/src/app/selling-avenues/page.tsx
new file mode 100644
index 0000000..41ecfb1
--- /dev/null
+++ b/src/app/selling-avenues/page.tsx
@@ -0,0 +1,138 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import {
+ SOURCING,
+ SELLING,
+ INTERNATIONAL,
+ COMPLIANCE,
+ WORKED_EXAMPLE,
+ type AvenueGroup,
+} from "@/lib/selling-avenues";
+
+const PAGE_TITLE = "Sourcing & Selling Avenues";
+const PAGE_DESC =
+ "Where to buy government surplus, where to resell it, and how to validate demand before you bid — with the compliance rules that keep 'sell before you buy' legal.";
+
+export const metadata: Metadata = {
+ title: PAGE_TITLE,
+ description: PAGE_DESC,
+ alternates: { canonical: "/selling-avenues" },
+ openGraph: {
+ title: PAGE_TITLE,
+ description: PAGE_DESC,
+ url: "/selling-avenues",
+ type: "website",
+ },
+};
+
+const faqJsonLd = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: COMPLIANCE.map((rule) => ({
+ "@type": "Question",
+ name: rule.title,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: rule.body,
+ },
+ })),
+};
+
+function Groups({ groups }: { groups: AvenueGroup[] }) {
+ return (
+ <div className="grid gap-3 md:grid-cols-3">
+ {groups.map((g) => (
+ <Card key={g.heading}>
+ <CardHeader>
+ <CardTitle className="text-foreground">{g.heading}</CardTitle>
+ <p className="text-xs text-muted-foreground">{g.blurb}</p>
+ </CardHeader>
+ <CardContent className="space-y-2">
+ {g.avenues.map((a) => (
+ <div key={a.url} className="text-sm">
+ <a
+ href={a.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="font-medium text-primary hover:underline"
+ >
+ {a.name} ↗
+ </a>
+ <p className="text-xs text-muted-foreground">{a.note}</p>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+}
+
+export default function SellingAvenuesPage() {
+ return (
+ <main className="mx-auto max-w-6xl space-y-6 p-5">
+ <script
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
+ />
+ <div className="text-sm">
+ <Link href="/" className="text-primary hover:underline">
+ ← Dashboard
+ </Link>
+ </div>
+ <header>
+ <h1 className="text-2xl font-semibold tracking-tight">Sourcing & Selling Avenues</h1>
+ <p className="text-sm text-muted-foreground">
+ Where to buy government surplus, where to resell it, and how to validate demand
+ <em> before </em> you bid — with the compliance rules that keep “sell before you buy” legal.
+ </p>
+ </header>
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">{WORKED_EXAMPLE.title}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-sm text-muted-foreground">{WORKED_EXAMPLE.body}</p>
+ </CardContent>
+ </Card>
+
+ <section className="space-y-3">
+ <h2 className="text-lg font-semibold">Where to source</h2>
+ <Groups groups={SOURCING} />
+ </section>
+
+ <section className="space-y-3">
+ <h2 className="text-lg font-semibold">Where to sell & validate demand</h2>
+ <Groups groups={SELLING} />
+ </section>
+
+ <section className="space-y-3">
+ <h2 className="text-lg font-semibold">
+ International — source & resell abroad (Europe · Japan · HK · Australia · China)
+ </h2>
+ <Groups groups={INTERNATIONAL} />
+ </section>
+
+ <section className="space-y-3">
+ <h2 className="text-lg font-semibold">
+ Compliance — selling before you own <Badge variant="warning">read this</Badge>
+ </h2>
+ <div className="grid gap-3 md:grid-cols-2">
+ {COMPLIANCE.map((r) => (
+ <Card key={r.title}>
+ <CardHeader>
+ <CardTitle className="text-foreground">{r.title}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-sm text-muted-foreground">{r.body}</p>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </section>
+ </main>
+ );
+}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 0000000..42e11a6
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,28 @@
+import type { MetadataRoute } from "next";
+import { listDigestDates } from "@/lib/digest-snapshot";
+import { publicBaseUrl } from "@/lib/site";
+
+export const dynamic = "force-dynamic";
+
+export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
+ const base = publicBaseUrl();
+ const dates = await listDigestDates();
+
+ const staticPages: MetadataRoute.Sitemap = [
+ { url: `${base}/`, changeFrequency: "daily", priority: 0.8 },
+ { url: `${base}/newsletter`, changeFrequency: "weekly", priority: 1 },
+ { url: `${base}/pricing`, changeFrequency: "weekly", priority: 0.7 },
+ { url: `${base}/deals`, changeFrequency: "daily", priority: 0.9 },
+ { url: `${base}/selling-avenues`, changeFrequency: "weekly", priority: 0.7 },
+ ];
+
+ return [
+ ...staticPages,
+ ...dates.map(({ date, lastModified }) => ({
+ url: `${base}/deals/${date}`,
+ lastModified,
+ changeFrequency: "monthly" as const, // frozen snapshots — they don't change
+ priority: 0.6,
+ })),
+ ];
+}
diff --git a/src/components/agent-finder.tsx b/src/components/agent-finder.tsx
new file mode 100644
index 0000000..dd7e3b2
--- /dev/null
+++ b/src/components/agent-finder.tsx
@@ -0,0 +1,349 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+type Kind = "buyer" | "leasing";
+type TypeOpt = "buyer" | "leasing" | "both";
+
+interface AgentResult {
+ id: string;
+ name: string;
+ address: string | null;
+ rating: number | null;
+ reviews: number;
+ phone: string | null;
+ website: string | null;
+ mapsUrl: string | null;
+ primaryType: string | null;
+ kinds: Kind[];
+}
+interface ApiResponse {
+ location: string;
+ kinds: Kind[];
+ results: AgentResult[];
+ queries: number;
+}
+
+const KIND_LABEL: Record<Kind, string> = { buyer: "Buyer / acquisition", leasing: "Leasing" };
+// Google Places Text Search (New) with contact + atmosphere fields ≈ $0.04 / query.
+const COST_PER_QUERY = 0.04;
+// The engine fires this many curated query lenses per kind (see KIND_LENSES in
+// src/lib/places.ts). Each lens is one billed Text Search call, so the estimate
+// must be lenses-per-kind × kinds × COST_PER_QUERY to match what actually bills.
+const LENSES_PER_KIND = 3;
+
+// Results grid — sort + density controls (Steve standing rule: every grid gets
+// sort + density, persisted to localStorage). Sorting is client-side over the
+// already-fetched results (no extra Places cost).
+type SortKey = "best" | "rating" | "reviews" | "name";
+const SORT_OPTIONS: { key: SortKey; label: string }[] = [
+ { key: "best", label: "Best match" },
+ { key: "rating", label: "Rating ↓" },
+ { key: "reviews", label: "Most reviews" },
+ { key: "name", label: "Name A→Z" },
+];
+const LS_SORT = "agents.sort";
+const LS_CARDMIN = "agents.cardMin";
+const CARDMIN_MIN = 220;
+const CARDMIN_MAX = 440;
+const CARDMIN_DEFAULT = 300;
+
+export function AgentFinder({
+ initialCity,
+ initialState,
+ initialZip,
+ initialType,
+}: {
+ initialCity: string;
+ initialState: string;
+ initialZip: string;
+ initialType: TypeOpt;
+}) {
+ const [city, setCity] = useState(initialCity);
+ const [state, setState] = useState(initialState);
+ const [zip, setZip] = useState(initialZip);
+ const [type, setType] = useState<TypeOpt>(initialType);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [data, setData] = useState<ApiResponse | null>(null);
+
+ // Sort + density (persisted). Read from localStorage after mount to avoid an
+ // SSR/hydration mismatch — both server and first client render use defaults,
+ // then we hydrate the stored prefs on the client. This is a deliberate
+ // synchronize-external-state-into-React effect (localStorage is the external
+ // store), so the set-state-in-effect rule is intentionally disabled here.
+ const [sort, setSort] = useState<SortKey>("best");
+ const [cardMin, setCardMin] = useState<number>(CARDMIN_DEFAULT);
+ useEffect(() => {
+ try {
+ const s = localStorage.getItem(LS_SORT) as SortKey | null;
+ /* eslint-disable react-hooks/set-state-in-effect -- deliberate: hydrate persisted prefs from localStorage (external store) after mount */
+ if (s && SORT_OPTIONS.some((o) => o.key === s)) setSort(s);
+ const d = Number(localStorage.getItem(LS_CARDMIN));
+ if (d >= CARDMIN_MIN && d <= CARDMIN_MAX) setCardMin(d);
+ /* eslint-enable react-hooks/set-state-in-effect */
+ } catch {}
+ }, []);
+ useEffect(() => {
+ try {
+ localStorage.setItem(LS_SORT, sort);
+ } catch {}
+ }, [sort]);
+ useEffect(() => {
+ try {
+ localStorage.setItem(LS_CARDMIN, String(cardMin));
+ } catch {}
+ }, [cardMin]);
+
+ const sortedResults = useMemo(() => {
+ const list = data ? [...data.results] : [];
+ switch (sort) {
+ case "rating":
+ return list.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0) || b.reviews - a.reviews);
+ case "reviews":
+ return list.sort((a, b) => b.reviews - a.reviews);
+ case "name":
+ return list.sort((a, b) => a.name.localeCompare(b.name));
+ default:
+ return list; // "best" = server's ranked order
+ }
+ }, [data, sort]);
+
+ async function run(e?: React.FormEvent) {
+ e?.preventDefault();
+ if (!city.trim() && !state.trim() && !zip.trim()) {
+ setError("Enter a city, state, or ZIP.");
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const qs = new URLSearchParams();
+ if (city.trim()) qs.set("city", city.trim());
+ if (state.trim()) qs.set("state", state.trim());
+ if (zip.trim()) qs.set("zip", zip.trim());
+ qs.set("type", type);
+ const res = await fetch(`/api/agents/search?${qs.toString()}`);
+ const json = await res.json();
+ if (!res.ok) throw new Error(json.error || "Search failed");
+ setData(json as ApiResponse);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Search failed");
+ setData(null);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const kindCount = type === "both" ? 2 : 1;
+ const estCost = kindCount * LENSES_PER_KIND * COST_PER_QUERY;
+
+ return (
+ <div className="space-y-5">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-foreground">Find commercial real-estate agents by location</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <form onSubmit={run} className="grid gap-3 sm:grid-cols-[1fr_110px_110px_auto]">
+ <Input
+ aria-label="City"
+ placeholder="City (e.g. Los Angeles)"
+ value={city}
+ onChange={(e) => setCity(e.target.value)}
+ />
+ <Input
+ aria-label="State"
+ placeholder="State (CA)"
+ value={state}
+ onChange={(e) => setState(e.target.value)}
+ />
+ <Input
+ aria-label="ZIP code"
+ placeholder="ZIP"
+ value={zip}
+ onChange={(e) => setZip(e.target.value)}
+ />
+ <Button type="submit" disabled={loading}>
+ {loading ? "Searching…" : "Search"}
+ </Button>
+ </form>
+ <div className="mt-3 flex flex-wrap items-center gap-2 text-sm" role="group" aria-labelledby="agent-type-label">
+ <span className="text-muted-foreground" id="agent-type-label">
+ Show:
+ </span>
+ {(["both", "buyer", "leasing"] as TypeOpt[]).map((t) => (
+ <button
+ key={t}
+ type="button"
+ aria-pressed={type === t}
+ onClick={() => setType(t)}
+ className={`rounded-full border px-3 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 ${
+ type === t
+ ? "border-primary text-primary"
+ : "border-border text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {t === "both" ? "Buyer + Leasing" : t === "buyer" ? "Buyer / acquisition" : "Leasing"}
+ </button>
+ ))}
+ </div>
+ <p className="mt-2 text-xs text-muted-foreground">
+ Commercial only — residential home agents are filtered out. Live Google Places lookup
+ (~${estCost.toFixed(2)} per search — {kindCount * LENSES_PER_KIND} queries
+ {type === "both" ? ", 2 categories × 3 lenses" : " (3 lenses)"}).
+ </p>
+ </CardContent>
+ </Card>
+
+ {error && (
+ <p role="alert" className="text-sm text-warning">
+ {error}
+ </p>
+ )}
+
+ {/* Screen-reader status: announces searching / result count changes. */}
+ <p className="sr-only" role="status" aria-live="polite">
+ {loading
+ ? "Searching for commercial agents…"
+ : data
+ ? `${data.results.length} commercial agents found near ${data.location}`
+ : ""}
+ </p>
+
+ {/* Loading skeleton — a shaped placeholder grid while the first search runs,
+ so the panel doesn't collapse to a bare "Searching…" button. */}
+ {loading && !data && (
+ <div
+ aria-hidden
+ className="grid gap-3"
+ style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
+ >
+ {Array.from({ length: 6 }).map((_, i) => (
+ <Card key={i}>
+ <CardContent className="space-y-3 p-4">
+ <div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
+ <div className="flex gap-1">
+ <div className="h-5 w-24 animate-pulse rounded-full bg-muted" />
+ <div className="h-5 w-16 animate-pulse rounded-full bg-muted" />
+ </div>
+ <div className="h-3 w-full animate-pulse rounded bg-muted" />
+ <div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )}
+
+ {/* Initial state — before any search, prompt instead of a blank panel. */}
+ {!loading && !data && !error && (
+ <Card>
+ <CardContent className="py-10 text-center text-sm text-muted-foreground">
+ Enter a city, state, or ZIP and search to find commercial real-estate agents nearby.
+ </CardContent>
+ </Card>
+ )}
+
+ {data && (
+ <div
+ className={`space-y-3 transition-opacity ${loading ? "pointer-events-none opacity-60" : ""}`}
+ aria-busy={loading}
+ >
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
+ <span>
+ {data.results.length} commercial agent{data.results.length === 1 ? "" : "s"} near{" "}
+ <span className="font-medium text-foreground">{data.location}</span>
+ </span>
+ <span>Cost this search: ${(data.queries * COST_PER_QUERY).toFixed(2)}</span>
+ </div>
+ {data.results.length === 0 && (
+ <p className="text-sm text-muted-foreground">No commercial agents found — try a broader location.</p>
+ )}
+ {data.results.length > 1 && (
+ <div className="flex flex-wrap items-center gap-4 text-sm">
+ <label className="flex items-center gap-2">
+ <span className="text-muted-foreground">Sort</span>
+ <select
+ aria-label="Sort results"
+ value={sort}
+ onChange={(e) => setSort(e.target.value as SortKey)}
+ className="rounded-md border border-border bg-transparent px-2 py-1 text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ >
+ {SORT_OPTIONS.map((o) => (
+ <option key={o.key} value={o.key}>
+ {o.label}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label className="flex items-center gap-2">
+ <span className="text-muted-foreground">Density</span>
+ {/* Right = denser (more, narrower cards): invert the card-min width. */}
+ <input
+ type="range"
+ min={CARDMIN_MIN}
+ max={CARDMIN_MAX}
+ step={20}
+ value={CARDMIN_MIN + CARDMIN_MAX - cardMin}
+ onChange={(e) => setCardMin(CARDMIN_MIN + CARDMIN_MAX - Number(e.target.value))}
+ className="accent-primary"
+ aria-label="Grid density"
+ />
+ </label>
+ </div>
+ )}
+ <div
+ className="grid gap-3"
+ style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
+ >
+ {sortedResults.map((a) => (
+ <Card key={a.id}>
+ <CardContent className="space-y-2 p-4 text-sm">
+ <div className="flex items-start justify-between gap-2">
+ <span className="font-medium text-foreground">{a.name}</span>
+ {a.rating != null && (
+ <span className="whitespace-nowrap text-xs text-muted-foreground">
+ ★ {a.rating.toFixed(1)} ({a.reviews})
+ </span>
+ )}
+ </div>
+ <div className="flex flex-wrap gap-1">
+ {a.kinds.map((k) => (
+ <Badge key={k} variant="primary">
+ {KIND_LABEL[k]}
+ </Badge>
+ ))}
+ {a.primaryType && <Badge variant="outline">{a.primaryType}</Badge>}
+ </div>
+ {a.address && <p className="text-muted-foreground">{a.address}</p>}
+ <div className="flex flex-wrap gap-3 pt-1 text-xs">
+ {a.phone && (
+ <a href={`tel:${a.phone}`} className="text-primary hover:underline">
+ {a.phone}
+ </a>
+ )}
+ {a.website && (
+ <a href={a.website} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
+ Website ↗
+ </a>
+ )}
+ {a.mapsUrl && (
+ <a href={a.mapsUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
+ Google Maps ↗
+ </a>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ <p className="pt-2 text-xs text-muted-foreground">Business data powered by Google.</p>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/src/components/dashboard-cards.tsx b/src/components/dashboard-cards.tsx
new file mode 100644
index 0000000..9f7a5ce
--- /dev/null
+++ b/src/components/dashboard-cards.tsx
@@ -0,0 +1,32 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { formatMoney, formatPercent } from "@/lib/utils";
+import type { DashboardStats } from "@/lib/dashboard";
+
+export function DashboardCards({ stats }: { stats: DashboardStats }) {
+ const cards: { title: string; value: string; hint?: string }[] = [
+ { title: "Active Auctions", value: String(stats.activeAuctions) },
+ { title: "Closing Today", value: String(stats.closingToday), hint: "next 24h" },
+ { title: "Highest Profit", value: formatMoney(stats.highestProfit), hint: "single lot" },
+ { title: "Highest ROI", value: formatPercent(stats.highestRoi), hint: "single lot" },
+ { title: "Cash Required", value: formatMoney(stats.cashRequired), hint: "sum of max bids" },
+ { title: "Expected Profit", value: formatMoney(stats.expectedProfit), hint: "portfolio net" },
+ { title: "Highest Score", value: String(Math.round(stats.highestScore)), hint: "opportunity" },
+ { title: "Pending Research", value: String(stats.pendingResearch) },
+ ];
+
+ return (
+ <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
+ {cards.map((c) => (
+ <Card key={c.title}>
+ <CardHeader className="pb-1">
+ <CardTitle>{c.title}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-semibold tabular-nums">{c.value}</div>
+ {c.hint && <div className="text-xs text-muted-foreground">{c.hint}</div>}
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+}
diff --git a/src/components/listings-table.tsx b/src/components/listings-table.tsx
new file mode 100644
index 0000000..4e3aa71
--- /dev/null
+++ b/src/components/listings-table.tsx
@@ -0,0 +1,519 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ useReactTable,
+ type ColumnDef,
+ type ColumnFiltersState,
+ type SortingState,
+ type VisibilityState,
+} from "@tanstack/react-table";
+import { useRouter } from "next/navigation";
+import type { ListingRow } from "@/lib/listings";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cn, formatMoney, formatPercent, countdown } from "@/lib/utils";
+import { auctionNetworkLinks, boardLinks, internationalTopLinks } from "@/lib/marketplace-links";
+
+type ColType =
+ | "text"
+ | "money"
+ | "score"
+ | "pct"
+ | "risk"
+ | "dropship"
+ | "countdown"
+ | "source"
+ | "networks"
+ | "boards"
+ | "intl";
+
+const LINK_TYPES: ColType[] = ["networks", "boards", "intl"];
+
+interface ColMeta {
+ key: keyof ListingRow;
+ label: string;
+ type: ColType;
+}
+
+// The full column set from the spec, in display order.
+const COLUMNS: ColMeta[] = [
+ { key: "title", label: "Title", type: "text" },
+ { key: "source", label: "Source", type: "source" },
+ { key: "sourceAuctionId", label: "Auction #", type: "text" },
+ { key: "category", label: "Category", type: "text" },
+ { key: "manufacturer", label: "Manufacturer", type: "text" },
+ { key: "model", label: "Model", type: "text" },
+ { key: "condition", label: "Condition", type: "text" },
+ { key: "quantity", label: "Qty", type: "score" },
+ { key: "currentBid", label: "Current Bid", type: "money" },
+ { key: "currentCost", label: "Current Cost", type: "money" },
+ { key: "recommendedMaxBid", label: "Rec. Max Bid", type: "money" },
+ { key: "retailLow", label: "Retail Low", type: "money" },
+ { key: "retailAverage", label: "Retail Avg", type: "money" },
+ { key: "retailHigh", label: "Retail High", type: "money" },
+ { key: "usedLow", label: "Used Low", type: "money" },
+ { key: "usedAverage", label: "Used Avg", type: "money" },
+ { key: "usedHigh", label: "Used High", type: "money" },
+ { key: "wholesale", label: "Wholesale", type: "money" },
+ { key: "liquidation", label: "Liquidation", type: "money" },
+ { key: "sellNow", label: "Sell Now", type: "money" },
+ { key: "value7Day", label: "7-Day", type: "money" },
+ { key: "value30Day", label: "30-Day", type: "money" },
+ { key: "value90Day", label: "90-Day", type: "money" },
+ { key: "expectedSale", label: "Expected Sale", type: "money" },
+ { key: "shipping", label: "Shipping", type: "money" },
+ { key: "freight", label: "Freight", type: "money" },
+ { key: "repairs", label: "Repairs", type: "money" },
+ { key: "marketplaceFees", label: "Mkt Fees", type: "money" },
+ { key: "netProfit", label: "Net Profit", type: "money" },
+ { key: "roi", label: "ROI", type: "pct" },
+ { key: "id", label: "Offers on Other Networks", type: "networks" },
+ { key: "id", label: "International Offers", type: "intl" },
+ { key: "id", label: "Goods-Needed Boards", type: "boards" },
+ { key: "risk", label: "Risk", type: "risk" },
+ { key: "confidence", label: "Confidence", type: "score" },
+ { key: "opportunityScore", label: "Opportunity", type: "score" },
+ { key: "arbitrageScore", label: "Arbitrage", type: "score" },
+ { key: "demandScore", label: "Demand", type: "score" },
+ { key: "velocityScore", label: "Velocity", type: "score" },
+ { key: "logisticsScore", label: "Logistics", type: "score" },
+ { key: "conditionScore", label: "Cond.", type: "score" },
+ { key: "competitionScore", label: "Comp.", type: "score" },
+ { key: "buyerScore", label: "Buyer", type: "score" },
+ { key: "dropShip", label: "Drop Ship", type: "dropship" },
+ { key: "closingAt", label: "Countdown", type: "countdown" },
+ { key: "researchStatus", label: "Research", type: "text" },
+];
+
+const PROFILES = [
+ ["OVERALL_OPPORTUNITY", "Overall Opportunity"],
+ ["BEST_ARBITRAGE", "Best Arbitrage"],
+ ["QUICK_FLIP", "Quick Flip"],
+ ["COLLECTOR", "Collector"],
+ ["LOCAL_PICKUP", "Local Pickup"],
+ ["EASY_FREIGHT", "Easy Freight"],
+ ["PARTS_ONLY", "Parts Only"],
+ ["HIGH_CONFIDENCE", "High Confidence"],
+ ["HIGH_PROFIT", "High Profit"],
+] as const;
+
+const SOURCES = ["GOVDEALS", "GOINDUSTRY", "NETWORK_INTL", "GRAYS_AU", "GOVPLANET", "MUNICIBID", "PUBLIC_SURPLUS", "GSA_AUCTIONS", "COUNTY", "STATE_SURPLUS", "UNIVERSITY_SURPLUS"];
+const CONDITIONS = ["NEW", "LIKE_NEW", "USED_GOOD", "USED_FAIR", "FOR_PARTS", "UNKNOWN"];
+const RISKS = ["LOW", "MEDIUM", "HIGH"];
+
+// Sort menu options ([value, label]; value = "columnId:dir"). SORT_VALUES lets us
+// distinguish a menu sort from an ad-hoc header-click sort so the <select> never
+// shows a false label (an off-menu header-click sort is session-only, not saved).
+const SORT_OPTIONS: [string, string][] = [
+ ["opportunityScore:desc", "Opportunity Score"],
+ ["arbitrageScore:desc", "Arbitrage Score"],
+ ["netProfit:desc", "Net Profit"],
+ ["expectedSale:desc", "Expected Sale"],
+ ["roi:desc", "ROI"],
+ ["closingAt:asc", "Closing (soonest)"],
+ ["currentBid:asc", "Current Bid ↑"],
+ ["title:asc", "Title A→Z"],
+];
+const SORT_VALUES = new Set(SORT_OPTIONS.map(([v]) => v));
+
+function scoreColor(v: number) {
+ if (v >= 75) return "text-[var(--positive)]";
+ if (v >= 50) return "text-[var(--warning)]";
+ return "text-muted-foreground";
+}
+
+function renderCell(row: ListingRow, meta: ColMeta) {
+ const v = row[meta.key];
+ switch (meta.type) {
+ case "money":
+ return <span className="tabular-nums">{formatMoney(v as number | null)}</span>;
+ case "pct":
+ return (
+ <span className={cn("tabular-nums", (v as number) >= 0.4 ? "text-[var(--positive)]" : "")}>
+ {formatPercent(v as number)}
+ </span>
+ );
+ case "score":
+ return <span className={cn("tabular-nums font-medium", scoreColor(Number(v)))}>{v == null ? "—" : Math.round(Number(v))}</span>;
+ case "risk":
+ return (
+ <Badge variant={v === "LOW" ? "positive" : v === "HIGH" ? "negative" : "warning"}>{String(v)}</Badge>
+ );
+ case "dropship":
+ return <Badge variant={v === "EASY" ? "positive" : v === "INFEASIBLE" ? "negative" : "outline"}>{String(v)}</Badge>;
+ case "source":
+ return <Badge variant="primary">{String(v).replace(/_/g, " ")}</Badge>;
+ case "countdown":
+ return <span className="tabular-nums">{countdown(v as string | null)}</span>;
+ case "networks":
+ case "boards":
+ case "intl": {
+ const chips =
+ meta.type === "networks"
+ ? auctionNetworkLinks(row)
+ : meta.type === "intl"
+ ? internationalTopLinks(row)
+ : boardLinks(row);
+ return (
+ <span className="flex gap-2 whitespace-nowrap">
+ {chips.slice(0, 4).map((c) => (
+ <a
+ key={c.url}
+ href={c.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ onClick={(e) => e.stopPropagation()}
+ className="text-xs text-primary hover:underline"
+ >
+ {c.label}↗
+ </a>
+ ))}
+ </span>
+ );
+ }
+ default:
+ return <span className="truncate">{v == null || v === "" ? "—" : String(v)}</span>;
+ }
+}
+
+export function ListingsTable() {
+ const router = useRouter();
+ const [rows, setRows] = useState<ListingRow[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [search, setSearch] = useState("");
+ const [source, setSource] = useState("");
+ const [condition, setCondition] = useState("");
+ const [risk, setRisk] = useState("");
+ const [profile, setProfile] = useState("OVERALL_OPPORTUNITY");
+ const [sorting, setSorting] = useState<SortingState>([{ id: "opportunityScore", desc: true }]);
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
+ const [showColMenu, setShowColMenu] = useState(false);
+ const [showFilters, setShowFilters] = useState(false);
+ // Sort select — mirrors the active sorting state so both the <select> and
+ // header clicks stay in sync. Persisted to localStorage.
+ const [sortKey, setSortKey] = useState("opportunityScore:desc");
+ // Density: 1=compact (py-1), 2=normal (py-2), 3=comfortable (py-4). Persisted.
+ const [density, setDensity] = useState(2);
+ // Skip the sort-sync effect's first run so it can't clobber the hydrated value.
+ const firstSortSync = useRef(true);
+
+ // Server-side search + filtering + profile selection.
+ useEffect(() => {
+ const ctrl = new AbortController();
+ const t = setTimeout(async () => {
+ setLoading(true);
+ // Server narrows by the coarse facets + profile; the free-text search and
+ // per-column filters run client-side across ALL fields over the loaded set.
+ const qs = new URLSearchParams({ pageSize: "1000", profile });
+ if (source) qs.set("source", source);
+ if (condition) qs.set("condition", condition);
+ if (risk) qs.set("risk", risk);
+ try {
+ const res = await fetch(`/api/listings?${qs}`, { signal: ctrl.signal });
+ const data = await res.json();
+ setRows(data.rows ?? []);
+ } catch {
+ /* aborted */
+ } finally {
+ setLoading(false);
+ }
+ }, 250);
+ return () => {
+ clearTimeout(t);
+ ctrl.abort();
+ };
+ }, [source, condition, risk, profile]);
+
+ // Hydrate sort + density from localStorage once on mount (SSR-safe: inside effect).
+ useEffect(() => {
+ const savedSort = localStorage.getItem("ga-listings-sort");
+ if (savedSort) {
+ setSortKey(savedSort);
+ const [id, dir] = savedSort.split(":");
+ setSorting([{ id, desc: dir === "desc" }]);
+ }
+ const savedDensity = localStorage.getItem("ga-listings-density");
+ if (savedDensity) {
+ const n = Number(savedDensity);
+ if (n >= 1 && n <= 3) setDensity(n);
+ }
+ }, []);
+
+ // Keep sortKey in sync when a header click changes sorting externally. Skip the
+ // first run (mount) so it can't overwrite the value hydrate just restored, and
+ // only PERSIST sorts that map to a menu option — an ad-hoc header-click sort is
+ // session-only (otherwise reload would restore a value the <select> has no
+ // <option> for and display a false label).
+ useEffect(() => {
+ if (firstSortSync.current) {
+ firstSortSync.current = false;
+ return;
+ }
+ if (sorting.length > 0) {
+ const next = `${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}`;
+ setSortKey(next);
+ if (SORT_VALUES.has(next)) localStorage.setItem("ga-listings-sort", next);
+ }
+ }, [sorting]);
+
+ const columns = useMemo<ColumnDef<ListingRow>[]>(
+ () =>
+ COLUMNS.map((meta) => {
+ const isLink = LINK_TYPES.includes(meta.type);
+ return {
+ ...(isLink ? { id: meta.type } : { accessorKey: meta.key }),
+ header: meta.label,
+ enableSorting: !isLink,
+ enableColumnFilter: !isLink,
+ enableGlobalFilter: !isLink,
+ filterFn: "includesString",
+ cell: ({ row }) => renderCell(row.original, meta),
+ } as ColumnDef<ListingRow>;
+ }),
+ [],
+ );
+
+ const table = useReactTable({
+ data: rows,
+ columns,
+ state: { sorting, columnVisibility, columnFilters, globalFilter: search },
+ onSortingChange: setSorting,
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setSearch,
+ // Global search matches EVERY field of a row (text, numbers, scores).
+ globalFilterFn: (row, _columnId, value) => {
+ if (!value) return true;
+ const q = String(value).toLowerCase();
+ return Object.values(row.original as unknown as Record<string, unknown>).some((v) =>
+ String(v ?? "").toLowerCase().includes(q),
+ );
+ },
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ });
+
+ const filteredCount = table.getFilteredRowModel().rows.length;
+
+ function exportCsv() {
+ const visible = COLUMNS.filter(
+ (c) => !LINK_TYPES.includes(c.type) && columnVisibility[c.key] !== false,
+ );
+ const header = visible.map((c) => c.label).join(",");
+ // Export exactly what's currently filtered + sorted on screen.
+ const data = table.getFilteredRowModel().rows.map((r) => r.original);
+ const lines = data.map((r) =>
+ visible.map((c) => JSON.stringify(r[c.key] ?? "")).join(","),
+ );
+ const csv = [header, ...lines].join("\n");
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "govarbitrage-listings.csv";
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ return (
+ <div className="space-y-3">
+ <div className="flex flex-wrap items-center gap-2">
+ <Input
+ placeholder="Search all fields…"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ className="max-w-xs"
+ />
+ <Button variant={showFilters ? "default" : "outline"} size="sm" onClick={() => setShowFilters((s) => !s)}>
+ Filters
+ </Button>
+ <Select value={profile} onChange={setProfile} options={PROFILES.map(([v, l]) => [v, l])} label="Profile" />
+ <Select value={source} onChange={setSource} options={[["", "All sources"], ...SOURCES.map((s) => [s, s.replace(/_/g, " ")] as [string, string])]} />
+ <Select value={condition} onChange={setCondition} options={[["", "All conditions"], ...CONDITIONS.map((c) => [c, c.replace(/_/g, " ")] as [string, string])]} />
+ <Select value={risk} onChange={setRisk} options={[["", "All risk"], ...RISKS.map((r) => [r, r] as [string, string])]} />
+ {/* Sort select — persisted to localStorage as "ga-listings-sort" */}
+ <Select
+ value={SORT_VALUES.has(sortKey) ? sortKey : "__custom__"}
+ label="Sort"
+ onChange={(v) => {
+ if (v === "__custom__") return; // placeholder for an ad-hoc header sort
+ setSortKey(v);
+ localStorage.setItem("ga-listings-sort", v);
+ const [id, dir] = v.split(":");
+ setSorting([{ id, desc: dir === "desc" }]);
+ }}
+ options={
+ SORT_VALUES.has(sortKey)
+ ? SORT_OPTIONS
+ : ([["__custom__", "Sorted by column ↕"], ...SORT_OPTIONS] as [string, string][])
+ }
+ />
+ {/* Density slider — persisted to localStorage as "ga-listings-density" */}
+ <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
+ Density
+ <input
+ type="range"
+ min={1}
+ max={3}
+ step={1}
+ value={density}
+ onChange={(e) => {
+ const n = Number(e.target.value);
+ setDensity(n);
+ localStorage.setItem("ga-listings-density", String(n));
+ }}
+ className="h-1.5 w-20 cursor-pointer accent-primary"
+ />
+ </label>
+ <div className="relative">
+ <Button variant="outline" size="sm" onClick={() => setShowColMenu((s) => !s)}>
+ Columns
+ </Button>
+ {showColMenu && (
+ <div className="absolute z-20 mt-1 max-h-80 w-56 overflow-auto rounded-md border bg-card p-2 shadow-lg">
+ {table.getAllLeafColumns().map((col) => (
+ <label key={col.id} className="flex items-center gap-2 py-0.5 text-xs">
+ <input
+ type="checkbox"
+ checked={col.getIsVisible()}
+ onChange={col.getToggleVisibilityHandler()}
+ />
+ {COLUMNS.find((c) => c.key === col.id)?.label ?? col.id}
+ </label>
+ ))}
+ </div>
+ )}
+ </div>
+ <Button variant="outline" size="sm" onClick={exportCsv}>
+ Export CSV
+ </Button>
+ {columnFilters.length > 0 && (
+ <Button variant="ghost" size="sm" onClick={() => setColumnFilters([])}>
+ Clear filters
+ </Button>
+ )}
+ <span className="ml-auto text-xs text-muted-foreground">
+ {loading ? "Loading…" : `${filteredCount} of ${rows.length} listings`}
+ </span>
+ </div>
+
+ <div className="overflow-auto rounded-xl border">
+ <table className="w-full border-collapse text-sm">
+ <thead className="sticky top-0 z-10 bg-card">
+ {table.getHeaderGroups().map((hg) => (
+ <tr key={hg.id}>
+ {hg.headers.map((header, i) => (
+ <th
+ key={header.id}
+ onClick={header.column.getToggleSortingHandler()}
+ className={cn(
+ "cursor-pointer select-none whitespace-nowrap border-b px-3 py-2 text-left text-xs font-semibold text-muted-foreground hover:text-foreground",
+ i === 0 && "sticky left-0 z-20 bg-card",
+ )}
+ >
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ {{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? ""}
+ </th>
+ ))}
+ </tr>
+ ))}
+ {showFilters && (
+ <tr>
+ {table.getHeaderGroups()[0]?.headers.map((header, i) => (
+ <th
+ key={header.id}
+ className={cn("border-b bg-card px-2 py-1", i === 0 && "sticky left-0 z-20 bg-card")}
+ >
+ {header.column.getCanFilter() ? (
+ <input
+ value={(header.column.getFilterValue() as string) ?? ""}
+ onChange={(e) => header.column.setFilterValue(e.target.value)}
+ onClick={(e) => e.stopPropagation()}
+ placeholder="filter…"
+ className="h-6 w-full min-w-[70px] rounded border bg-transparent px-1 text-xs font-normal outline-none focus:ring-1 focus:ring-primary"
+ />
+ ) : null}
+ </th>
+ ))}
+ </tr>
+ )}
+ </thead>
+ <tbody>
+ {loading && (
+ <tr>
+ <td colSpan={COLUMNS.length} className="px-3 py-10 text-center text-muted-foreground">
+ <span className="inline-flex items-center gap-2">
+ <span className="animate-spin inline-block h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
+ Loading listings…
+ </span>
+ </td>
+ </tr>
+ )}
+ {!loading && table.getRowModel().rows.map((row) => (
+ <tr
+ key={row.id}
+ onClick={() => router.push(`/listings/${row.original.id}`)}
+ className="cursor-pointer border-b hover:bg-accent/50"
+ >
+ {row.getVisibleCells().map((cell, i) => (
+ <td
+ key={cell.id}
+ className={cn(
+ "whitespace-nowrap px-3",
+ density === 1 ? "py-1" : density === 3 ? "py-4" : "py-2",
+ i === 0 && "sticky left-0 z-10 max-w-[280px] truncate bg-card font-medium",
+ )}
+ >
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </td>
+ ))}
+ </tr>
+ ))}
+ {!loading && filteredCount === 0 && (
+ <tr>
+ <td colSpan={COLUMNS.length} className="px-3 py-10 text-center text-muted-foreground">
+ No listings match your filters.
+ </td>
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+}
+
+function Select({
+ value,
+ onChange,
+ options,
+ label,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ options: [string, string][];
+ label?: string;
+}) {
+ return (
+ <select
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ title={label}
+ className="h-8 rounded-md border bg-transparent px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-primary"
+ >
+ {options.map(([v, l]) => (
+ <option key={v} value={v} className="bg-card">
+ {l}
+ </option>
+ ))}
+ </select>
+ );
+}
diff --git a/src/components/logout-button.tsx b/src/components/logout-button.tsx
new file mode 100644
index 0000000..1b177b9
--- /dev/null
+++ b/src/components/logout-button.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+
+export function LogoutButton() {
+ const router = useRouter();
+ async function logout() {
+ await fetch("/api/auth/logout", { method: "POST" });
+ router.push("/login");
+ router.refresh();
+ }
+ return (
+ <Button variant="outline" size="sm" onClick={logout}>
+ Sign out
+ </Button>
+ );
+}
diff --git a/src/components/offer-form.tsx b/src/components/offer-form.tsx
new file mode 100644
index 0000000..9f9f42c
--- /dev/null
+++ b/src/components/offer-form.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+export function OfferForm({ listingId }: { listingId: string }) {
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [offer, setOffer] = useState("");
+ const [status, setStatus] = useState<"idle" | "sending" | "done" | "error">("idle");
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setStatus("sending");
+ try {
+ const res = await fetch(`/api/listings/${listingId}/buyer-lead`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name,
+ email,
+ offer: offer ? Number(offer) : undefined,
+ }),
+ });
+ setStatus(res.ok ? "done" : "error");
+ } catch {
+ setStatus("error");
+ }
+ }
+
+ if (status === "done") {
+ return (
+ <p className="rounded-md border border-[var(--positive)]/40 bg-[var(--positive)]/10 p-3 text-sm">
+ Thanks — your <strong>contingent, non-binding</strong> interest is recorded. We'll follow up
+ only if we win this lot.
+ </p>
+ );
+ }
+
+ return (
+ <form onSubmit={submit} className="space-y-2">
+ <Input placeholder="Your name" value={name} onChange={(e) => setName(e.target.value)} required />
+ <Input placeholder="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
+ <Input
+ placeholder="Contingent offer (optional, USD)"
+ type="number"
+ value={offer}
+ onChange={(e) => setOffer(e.target.value)}
+ />
+ <Button type="submit" disabled={status === "sending"}>
+ {status === "sending" ? "Submitting…" : "Register contingent interest"}
+ </Button>
+ {status === "error" && <p className="text-sm text-[var(--negative)]">Something went wrong — try again.</p>}
+ </form>
+ );
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..e6ed8a5
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,27 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ default: "bg-accent text-foreground",
+ primary: "bg-primary/15 text-primary",
+ positive: "bg-[var(--positive)]/15 text-[var(--positive)]",
+ negative: "bg-[var(--negative)]/15 text-[var(--negative)]",
+ warning: "bg-[var(--warning)]/15 text-[var(--warning)]",
+ outline: "border text-muted-foreground",
+ },
+ },
+ defaultVariants: { variant: "default" },
+ },
+);
+
+export function Badge({
+ className,
+ variant,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
+ return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
+}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..c17f58f
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,29 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:opacity-90",
+ outline: "border bg-transparent hover:bg-accent",
+ ghost: "hover:bg-accent",
+ },
+ size: {
+ default: "h-9 px-4",
+ sm: "h-8 px-3 text-xs",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: { variant: "default", size: "default" },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+ VariantProps<typeof buttonVariants> {}
+
+export function Button({ className, variant, size, ...props }: ButtonProps) {
+ return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
+}
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..d66fb43
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,22 @@
+import { cn } from "@/lib/utils";
+
+export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+ return (
+ <div
+ className={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)}
+ {...props}
+ />
+ );
+}
+
+export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("flex flex-col gap-1 p-4", className)} {...props} />;
+}
+
+export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("text-sm font-medium text-muted-foreground", className)} {...props} />;
+}
+
+export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+ return <div className={cn("p-4 pt-0", className)} {...props} />;
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..f06ad15
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils";
+
+export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
+ return (
+ <input
+ className={cn(
+ "h-9 w-full rounded-md border bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
diff --git a/src/engines/constants.ts b/src/engines/constants.ts
new file mode 100644
index 0000000..73e70c6
--- /dev/null
+++ b/src/engines/constants.ts
@@ -0,0 +1,56 @@
+import type { ConditionKey, SourceKey } from "./types";
+
+// Fraction of *new retail* a used unit fetches, by condition. These are the
+// backbone of the valuation model; documented so estimates are auditable.
+export const CONDITION_RETAIL_FACTOR: Record<ConditionKey, number> = {
+ NEW: 0.78,
+ LIKE_NEW: 0.62,
+ USED_GOOD: 0.46,
+ USED_FAIR: 0.31,
+ FOR_PARTS: 0.12,
+ UNKNOWN: 0.35,
+};
+
+// Typical repair burden as a fraction of new retail, by condition.
+export const CONDITION_REPAIR_FACTOR: Record<ConditionKey, number> = {
+ NEW: 0,
+ LIKE_NEW: 0.01,
+ USED_GOOD: 0.03,
+ USED_FAIR: 0.07,
+ FOR_PARTS: 0.18,
+ UNKNOWN: 0.05,
+};
+
+// Buyer's premium charged by each source (fraction of hammer/winning bid).
+export const BUYER_PREMIUM_RATE: Record<SourceKey, number> = {
+ GOVDEALS: 0.1,
+ PUBLIC_SURPLUS: 0.1,
+ GSA_AUCTIONS: 0,
+ COUNTY: 0.1,
+ STATE_SURPLUS: 0.08,
+ UNIVERSITY_SURPLUS: 0.1,
+ MUNICIBID: 0.1,
+ BID4ASSETS: 0.1,
+ CSV: 0.1,
+ EXTENSION: 0.1,
+ OTHER: 0.1,
+};
+
+// Marketplace + payment fee rates (fraction of resale price).
+export const MARKETPLACE_FEE_RATE = 0.132; // eBay-typical final value fee
+export const PAYMENT_FEE_RATE = 0.03; // managed-payments processing
+
+// Prep/handling cost assumptions (US dollars).
+export const PACKING_BASE = 12;
+export const PACKING_PER_LB = 0.15;
+export const PHOTOGRAPHY_COST = 8;
+export const LISTING_LABOR_COST = 10;
+export const TESTING_COST_PER_UNIT = 15;
+export const PICKUP_LABOR_HOURLY = 35;
+export const STORAGE_PER_MONTH = 18;
+
+// Weight threshold (lbs) above which an item ships as LTL freight, not parcel.
+export const FREIGHT_THRESHOLD_LBS = 150;
+
+// Target ROI used to back-solve the recommended maximum bid.
+export const TARGET_ROI = 0.4;
diff --git a/src/engines/costs.test.ts b/src/engines/costs.test.ts
new file mode 100644
index 0000000..e4984df
--- /dev/null
+++ b/src/engines/costs.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { computeCosts } from "./costs";
+import type { CostInput } from "./types";
+
+const base: CostInput = {
+ source: "GOVDEALS",
+ winningBid: 500,
+ quantity: 1,
+ weightLbs: 40,
+ condition: "USED_GOOD",
+ expectedSalePrice: 9000,
+ daysUntilSold: 30,
+};
+
+describe("computeCosts", () => {
+ it("applies the source buyer premium", () => {
+ const c = computeCosts(base);
+ // GovDeals premium is 10% of the winning bid.
+ expect(c.buyerPremium).toBeCloseTo(50, 2);
+ });
+
+ it("ships parcel under the freight threshold and freight above it", () => {
+ const light = computeCosts({ ...base, weightLbs: 40 });
+ expect(light.freight).toBe(0);
+ expect(light.shipping).toBeGreaterThan(0);
+
+ const heavy = computeCosts({ ...base, weightLbs: 600 });
+ expect(heavy.freight).toBeGreaterThan(0);
+ expect(heavy.shipping).toBe(0);
+ });
+
+ it("computes a positive net profit for a strong flip", () => {
+ const c = computeCosts(base);
+ expect(c.expectedNetProfit).toBeGreaterThan(0);
+ expect(c.roi).toBeGreaterThan(0);
+ expect(c.totalInvestment).toBeGreaterThan(base.winningBid);
+ });
+
+ it("recommends a max bid that back-solves to the target ROI", () => {
+ const c = computeCosts(base);
+ // Bidding exactly the recommended max should yield ~40% ROI.
+ const atMax = computeCosts({ ...base, winningBid: c.recommendedMaxBid });
+ expect(atMax.roi).toBeCloseTo(0.4, 1);
+ });
+
+ it("zeroes shipping/freight but adds pickup labor for local pickup", () => {
+ const c = computeCosts({ ...base, weightLbs: 600, localPickup: true });
+ expect(c.shipping).toBe(0);
+ expect(c.freight).toBe(0);
+ expect(c.pickupLabor).toBeGreaterThan(0);
+ });
+
+ it("stays coherent for high-quantity lots (qty > 1)", () => {
+ // Quantity-scaled lot: expectedSalePrice is the lot total; costs must remain
+ // internally consistent (positive investment, sane max bid below returns).
+ const lot = computeCosts({
+ ...base,
+ quantity: 20,
+ weightLbs: 120,
+ expectedSalePrice: 26000,
+ });
+ expect(lot.testing).toBeCloseTo(20 * 15, 2); // testing scales per unit
+ expect(lot.totalInvestment).toBeGreaterThan(0);
+ expect(lot.recommendedMaxBid).toBeGreaterThan(0);
+ expect(lot.recommendedMaxBid).toBeLessThan(lot.expectedReturns);
+ // Bidding the recommended max still back-solves to the target ROI at qty>1.
+ const atMax = computeCosts({ ...base, quantity: 20, weightLbs: 120, expectedSalePrice: 26000, winningBid: lot.recommendedMaxBid });
+ expect(atMax.roi).toBeCloseTo(0.4, 1);
+ });
+
+ it("applies purchase sales tax when a rate is provided", () => {
+ const taxed = computeCosts({ ...base, purchaseTaxRate: 0.0725 });
+ const untaxed = computeCosts({ ...base, purchaseTaxRate: 0 });
+ expect(taxed.salesTax).toBeGreaterThan(0);
+ expect(untaxed.salesTax).toBe(0);
+ expect(taxed.totalInvestment).toBeGreaterThan(untaxed.totalInvestment);
+ });
+});
diff --git a/src/engines/costs.ts b/src/engines/costs.ts
new file mode 100644
index 0000000..9b8ac7a
--- /dev/null
+++ b/src/engines/costs.ts
@@ -0,0 +1,162 @@
+import { round2, clamp } from "@/lib/utils";
+import {
+ BUYER_PREMIUM_RATE,
+ CONDITION_REPAIR_FACTOR,
+ LISTING_LABOR_COST,
+ MARKETPLACE_FEE_RATE,
+ PACKING_BASE,
+ PACKING_PER_LB,
+ PAYMENT_FEE_RATE,
+ PHOTOGRAPHY_COST,
+ PICKUP_LABOR_HOURLY,
+ STORAGE_PER_MONTH,
+ TARGET_ROI,
+ TESTING_COST_PER_UNIT,
+} from "./constants";
+import { estimateFreight } from "./freight";
+import type { CostBreakdown, CostInput } from "./types";
+
+/** Costs that do NOT depend on the winning bid (prep, logistics, fees). */
+function fixedCosts(input: CostInput) {
+ const qty = Math.max(1, input.quantity || 1);
+ const weight = Math.max(0, input.weightLbs || 0);
+ const totalWeight = weight; // weightLbs is the lot total in our model
+
+ const fr = estimateFreight(totalWeight);
+ const shipping = input.localPickup ? 0 : fr.parcelShipping;
+ const freight = input.localPickup ? 0 : fr.freight;
+
+ const insurance = round2(input.expectedSalePrice * 0.01);
+ const packing = round2(PACKING_BASE + PACKING_PER_LB * totalWeight);
+ const pickupLabor = input.localPickup
+ ? round2(PICKUP_LABOR_HOURLY * (1 + totalWeight / 500)) // ~1h + weight handling
+ : 0;
+ const testing = round2(TESTING_COST_PER_UNIT * qty);
+ const repairs =
+ input.repairsOverride ??
+ round2(CONDITION_REPAIR_FACTOR[input.condition] * input.expectedSalePrice);
+ // Certification only meaningful for regulated categories; default 0, overridable.
+ const certification = 0;
+ const months = Math.max(0.25, input.daysUntilSold / 30);
+ const storage = round2(STORAGE_PER_MONTH * months);
+ const photography = PHOTOGRAPHY_COST;
+ const listingLabor = LISTING_LABOR_COST;
+
+ // Resale-side fees scale with sale price, not bid.
+ const marketplaceFees = round2(input.expectedSalePrice * MARKETPLACE_FEE_RATE);
+ const paymentFees = round2(input.expectedSalePrice * PAYMENT_FEE_RATE);
+
+ const fixedTotal = round2(
+ shipping +
+ freight +
+ insurance +
+ packing +
+ pickupLabor +
+ testing +
+ repairs +
+ certification +
+ storage +
+ photography +
+ listingLabor,
+ );
+
+ return {
+ shipping,
+ freight,
+ insurance,
+ packing,
+ pickupLabor,
+ testing,
+ repairs,
+ certification,
+ storage,
+ photography,
+ listingLabor,
+ marketplaceFees,
+ paymentFees,
+ fixedTotal,
+ freightBasis: fr.basis,
+ };
+}
+
+/**
+ * Full cost + profitability breakdown for a listing at a given winning bid.
+ * Also back-solves `recommendedMaxBid`: the highest bid that still clears the
+ * TARGET_ROI threshold given all bid-independent costs and resale fees.
+ */
+export function computeCosts(input: CostInput): CostBreakdown {
+ const source = input.source;
+ const premiumRate = BUYER_PREMIUM_RATE[source] ?? 0.1;
+ const taxRate = input.purchaseTaxRate ?? 0; // resale cert typically zeroes this
+ const f = fixedCosts(input);
+
+ const bidCost = (bid: number) => {
+ const buyerPremium = round2(bid * premiumRate);
+ const salesTax = round2((bid + buyerPremium) * taxRate);
+ return { buyerPremium, salesTax, acquire: round2(bid + buyerPremium + salesTax) };
+ };
+
+ const { buyerPremium, salesTax, acquire } = bidCost(input.winningBid);
+
+ const expectedReturns = round2(
+ input.expectedSalePrice - f.marketplaceFees - f.paymentFees,
+ );
+ const totalInvestment = round2(acquire + f.fixedTotal);
+ const expectedNetProfit = round2(expectedReturns - totalInvestment);
+ const roi = totalInvestment > 0 ? round2(expectedNetProfit / totalInvestment) : 0;
+
+ // Annualize the ROI over the expected holding period.
+ const days = Math.max(1, input.daysUntilSold);
+ const annualizedReturn =
+ roi > -1
+ ? round2(Math.pow(1 + roi, 365 / days) - 1)
+ : -1;
+
+ // Recommended max bid: solve netProfit(bid) = TARGET_ROI * totalInvestment(bid).
+ // netProfit = expectedReturns - (bid*(1+prem)*(1+tax) + fixedTotal)
+ // Set roi target: (expectedReturns - acquire - fixed) / (acquire + fixed) = TARGET
+ // => expectedReturns - acquire - fixed = TARGET*(acquire + fixed)
+ // => expectedReturns = (1+TARGET)*(acquire + fixed)
+ // => acquire = expectedReturns/(1+TARGET) - fixed
+ // acquire = bid*(1+prem)*(1+tax) => bid = acquire / ((1+prem)*(1+tax))
+ const acquireBudget =
+ expectedReturns / (1 + TARGET_ROI) - f.fixedTotal;
+ const bidMultiplier = (1 + premiumRate) * (1 + taxRate);
+ const recommendedMaxBid = round2(
+ clamp(acquireBudget / bidMultiplier, 0, Number.MAX_SAFE_INTEGER),
+ );
+
+ return {
+ winningBid: round2(input.winningBid),
+ buyerPremium,
+ salesTax,
+ shipping: f.shipping,
+ freight: f.freight,
+ insurance: f.insurance,
+ packing: f.packing,
+ pickupLabor: f.pickupLabor,
+ testing: f.testing,
+ repairs: f.repairs,
+ certification: f.certification,
+ marketplaceFees: f.marketplaceFees,
+ paymentFees: f.paymentFees,
+ storage: f.storage,
+ photography: f.photography,
+ listingLabor: f.listingLabor,
+ expectedReturns,
+ totalInvestment,
+ expectedNetProfit,
+ roi,
+ annualizedReturn,
+ recommendedMaxBid,
+ assumptions: {
+ buyerPremiumRate: premiumRate,
+ purchaseTaxRate: taxRate,
+ marketplaceFeeRate: MARKETPLACE_FEE_RATE,
+ paymentFeeRate: PAYMENT_FEE_RATE,
+ targetRoi: TARGET_ROI,
+ freightBasis: f.freightBasis,
+ holdingDays: days,
+ },
+ };
+}
diff --git a/src/engines/demand.ts b/src/engines/demand.ts
new file mode 100644
index 0000000..e911f2b
--- /dev/null
+++ b/src/engines/demand.ts
@@ -0,0 +1,73 @@
+import { clamp } from "@/lib/utils";
+
+// Deterministic, $0 demand + retail heuristic. Used as the fallback when no AI
+// model is reachable, and as a sanity floor for AI estimates. Keyword-driven so
+// it is fully explainable.
+
+interface CategorySignal {
+ demand: number; // 0..100 baseline secondary-market liquidity
+ retailFloor: number; // rough per-unit new-retail baseline (USD)
+ keywords: string[];
+}
+
+const CATEGORY_SIGNALS: CategorySignal[] = [
+ { demand: 82, retailFloor: 900, keywords: ["laptop", "macbook", "thinkpad", "computer", "workstation"] },
+ { demand: 80, retailFloor: 600, keywords: ["iphone", "ipad", "tablet", "phone", "surface"] },
+ { demand: 70, retailFloor: 400, keywords: ["monitor", "display", "projector", "tv", "television"] },
+ { demand: 74, retailFloor: 4500, keywords: ["dental", "medical", "surgical", "exam", "patient", "dental chair", "autoclave"] },
+ { demand: 68, retailFloor: 1200, keywords: ["microscope", "lab", "laboratory", "analyzer", "centrifuge", "spectrometer"] },
+ { demand: 66, retailFloor: 2200, keywords: ["forklift", "generator", "compressor", "welder", "lathe", "cnc"] },
+ { demand: 60, retailFloor: 800, keywords: ["herman miller", "aeron", "steelcase", "office chair", "ergonomic"] },
+ { demand: 45, retailFloor: 300, keywords: ["desk", "cabinet", "table", "furniture", "shelving", "credenza"] },
+ { demand: 72, retailFloor: 550, keywords: ["drone", "camera", "lens", "nikon", "canon", "sony", "gopro"] },
+ { demand: 64, retailFloor: 700, keywords: ["radio", "motorola", "network", "cisco", "switch", "router", "server"] },
+ { demand: 58, retailFloor: 500, keywords: ["tool", "dewalt", "milwaukee", "makita", "power tool", "generator"] },
+ { demand: 50, retailFloor: 250, keywords: ["vehicle", "truck", "trailer", "atv", "mower", "tractor"] },
+];
+
+const PREMIUM_BRANDS = [
+ "herman miller",
+ "steelcase",
+ "apple",
+ "macbook",
+ "dewalt",
+ "milwaukee",
+ "cisco",
+ "midmark",
+ "adec",
+ "leica",
+ "nikon",
+];
+
+export interface DemandEstimate {
+ demandScore: number;
+ retailFloor: number;
+ matchedCategory: string | null;
+ brandBoost: boolean;
+}
+
+/** Estimate demand + a retail floor from listing text keywords. */
+export function estimateDemand(text: string): DemandEstimate {
+ const hay = text.toLowerCase();
+ let best: CategorySignal | null = null;
+ let bestHits = 0;
+
+ for (const sig of CATEGORY_SIGNALS) {
+ const hits = sig.keywords.filter((k) => hay.includes(k)).length;
+ if (hits > bestHits) {
+ bestHits = hits;
+ best = sig;
+ }
+ }
+
+ const brandBoost = PREMIUM_BRANDS.some((b) => hay.includes(b));
+ const baseDemand = best ? best.demand : 40;
+ const demandScore = clamp(baseDemand + (brandBoost ? 8 : 0), 0, 100);
+
+ return {
+ demandScore,
+ retailFloor: best ? best.retailFloor : 200,
+ matchedCategory: best ? best.keywords[0] : null,
+ brandBoost,
+ };
+}
diff --git a/src/engines/freight.ts b/src/engines/freight.ts
new file mode 100644
index 0000000..2541002
--- /dev/null
+++ b/src/engines/freight.ts
@@ -0,0 +1,47 @@
+import { round2 } from "@/lib/utils";
+import { FREIGHT_THRESHOLD_LBS } from "./constants";
+
+export interface FreightEstimate {
+ parcelShipping: number;
+ freight: number;
+ isFreight: boolean;
+ basis: string;
+}
+
+/**
+ * Estimate outbound logistics cost from total lot weight. Light items ship as
+ * parcel (USPS/UPS ground tiers); heavy items move as LTL freight with a base
+ * charge plus per-hundredweight (CWT) rate. Deliberately simple + explainable.
+ */
+export function estimateFreight(totalWeightLbs: number): FreightEstimate {
+ const w = Math.max(0, totalWeightLbs || 0);
+
+ if (w <= FREIGHT_THRESHOLD_LBS) {
+ // Parcel tiers.
+ let parcel: number;
+ if (w <= 1) parcel = 6;
+ else if (w <= 5) parcel = 12;
+ else if (w <= 20) parcel = 22;
+ else if (w <= 50) parcel = 45;
+ else if (w <= 100) parcel = 85;
+ else parcel = 130;
+ return {
+ parcelShipping: round2(parcel),
+ freight: 0,
+ isFreight: false,
+ basis: `parcel @ ${w} lb`,
+ };
+ }
+
+ // LTL freight: base handling + per-CWT (hundredweight) rate.
+ const base = 95;
+ const perCwt = 28;
+ const cwt = w / 100;
+ const freight = round2(base + perCwt * cwt);
+ return {
+ parcelShipping: 0,
+ freight,
+ isFreight: true,
+ basis: `LTL: $${base} base + $${perCwt}/cwt × ${cwt.toFixed(2)}cwt`,
+ };
+}
diff --git a/src/engines/scoring.test.ts b/src/engines/scoring.test.ts
new file mode 100644
index 0000000..acc3bbe
--- /dev/null
+++ b/src/engines/scoring.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { computeScores, computeComponents } from "./scoring";
+import type { ScoreInput } from "./scoring";
+
+const base: ScoreInput = {
+ roi: 0.6,
+ expectedNetProfit: 3000,
+ demandScore: 70,
+ daysUntilSold: 25,
+ weightLbs: 40,
+ condition: "USED_GOOD",
+ bidCount: 2,
+ localPickup: false,
+ hasBuyerLeads: false,
+ confidenceScore: 80,
+ probabilityOfSale: 0.7,
+ isFreight: false,
+};
+
+describe("computeScores", () => {
+ it("returns all nine scoring profiles", () => {
+ const scores = computeScores(base);
+ expect(scores).toHaveLength(9);
+ const profiles = scores.map((s) => s.profile).sort();
+ expect(profiles).toContain("OVERALL_OPPORTUNITY");
+ expect(profiles).toContain("BEST_ARBITRAGE");
+ expect(profiles).toContain("PARTS_ONLY");
+ });
+
+ it("every score is 0..100 and carries a non-empty explanation", () => {
+ for (const s of computeScores(base)) {
+ expect(s.value).toBeGreaterThanOrEqual(0);
+ expect(s.value).toBeLessThanOrEqual(100);
+ expect(s.explanation.length).toBeGreaterThan(20);
+ expect(s.factors.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("rewards a genuine salvage lot under PARTS_ONLY and penalizes non-parts", () => {
+ const parts = computeScores({ ...base, condition: "FOR_PARTS" }).find(
+ (s) => s.profile === "PARTS_ONLY",
+ )!;
+ const notParts = computeScores({ ...base, condition: "NEW" }).find(
+ (s) => s.profile === "PARTS_ONLY",
+ )!;
+ expect(parts.value).toBeGreaterThan(notParts.value);
+ });
+
+ it("higher ROI raises the arbitrage component and BEST_ARBITRAGE score", () => {
+ const low = computeScores({ ...base, roi: 0.05 }).find((s) => s.profile === "BEST_ARBITRAGE")!;
+ const high = computeScores({ ...base, roi: 1.5 }).find((s) => s.profile === "BEST_ARBITRAGE")!;
+ expect(high.value).toBeGreaterThan(low.value);
+ });
+
+ it("flags HIGH risk on low confidence + thin margin", () => {
+ const risky = computeScores({
+ ...base,
+ confidenceScore: 30,
+ roi: 0.05,
+ condition: "UNKNOWN",
+ daysUntilSold: 100,
+ })[0];
+ expect(risky.risk).toBe("HIGH");
+ });
+
+ it("marks light non-pickup items as EASY drop-ship, heavy freight as harder", () => {
+ const easy = computeComponents(base);
+ expect(easy.logistics).toBeGreaterThan(50);
+ const heavy = computeScores({ ...base, weightLbs: 700, isFreight: true })[0];
+ expect(["DIFFICULT", "INFEASIBLE", "MODERATE"]).toContain(heavy.dropShip);
+ });
+});
diff --git a/src/engines/scoring.ts b/src/engines/scoring.ts
new file mode 100644
index 0000000..8f45118
--- /dev/null
+++ b/src/engines/scoring.ts
@@ -0,0 +1,196 @@
+import { clamp, round2, formatPercent, formatMoney } from "@/lib/utils";
+import type {
+ ConditionKey,
+ DropShipKey,
+ ProfileScore,
+ RiskKey,
+ ScoreComponents,
+ ScoreProfileKey,
+} from "./types";
+
+export interface ScoreInput {
+ roi: number;
+ expectedNetProfit: number;
+ demandScore: number; // 0..100
+ daysUntilSold: number;
+ weightLbs: number;
+ condition: ConditionKey;
+ bidCount: number;
+ localPickup: boolean;
+ hasBuyerLeads: boolean;
+ confidenceScore: number; // 0..100
+ probabilityOfSale: number; // 0..1
+ isFreight: boolean;
+}
+
+const CONDITION_SCORE: Record<ConditionKey, number> = {
+ NEW: 95,
+ LIKE_NEW: 82,
+ USED_GOOD: 65,
+ USED_FAIR: 45,
+ FOR_PARTS: 22,
+ UNKNOWN: 40,
+};
+
+/** Map an ROI ratio onto a 0..100 arbitrage score with diminishing returns. */
+function arbitrageFromRoi(roi: number): number {
+ // roi -0.5 -> ~5, 0 -> 35, 0.4 -> 70, 1.0 -> 88, 2.0+ -> ~98
+ const s = 35 + 55 * (1 - Math.exp(-1.15 * Math.max(0, roi))) + (roi < 0 ? roi * 60 : 0);
+ return clamp(s, 0, 100);
+}
+
+/** Compute the seven component sub-scores. */
+export function computeComponents(input: ScoreInput): ScoreComponents {
+ const arbitrage = round2(arbitrageFromRoi(input.roi));
+ const demand = round2(clamp(input.demandScore, 0, 100));
+ const velocity = round2(clamp(100 - input.daysUntilSold * 0.72, 0, 100));
+
+ // Logistics: lighter + parcel-shippable is best; freight and heavy items hurt.
+ let logistics = 100 - Math.min(input.weightLbs, 1000) * 0.06;
+ if (input.isFreight) logistics -= 25;
+ if (input.localPickup) logistics -= 10; // pickup adds friction unless local buyer
+ logistics = round2(clamp(logistics, 0, 100));
+
+ const condition = CONDITION_SCORE[input.condition] ?? 40;
+ const competition = round2(clamp(95 - input.bidCount * 6, 5, 100));
+ const buyer = round2(
+ clamp(input.probabilityOfSale * 70 + (input.hasBuyerLeads ? 25 : 0) + input.demandScore * 0.1, 0, 100),
+ );
+
+ return { arbitrage, demand, velocity, logistics, condition, competition, buyer };
+}
+
+export function computeRisk(input: ScoreInput, c: ScoreComponents): RiskKey {
+ let risk = 0;
+ if (input.confidenceScore < 45) risk += 2;
+ else if (input.confidenceScore < 70) risk += 1;
+ if (input.roi < 0.15) risk += 2;
+ else if (input.roi < 0.35) risk += 1;
+ if (input.condition === "FOR_PARTS" || input.condition === "UNKNOWN") risk += 1;
+ if (c.velocity < 40) risk += 1;
+ if (risk >= 4) return "HIGH";
+ if (risk >= 2) return "MEDIUM";
+ return "LOW";
+}
+
+export function computeDropShip(input: ScoreInput): DropShipKey {
+ // Drop-ship feasibility: can this move seller -> buyer without you touching it?
+ if (input.localPickup && input.weightLbs > 300) return "INFEASIBLE";
+ if (input.isFreight) return input.weightLbs > 500 ? "DIFFICULT" : "MODERATE";
+ if (input.weightLbs <= 50 && !input.localPickup) return "EASY";
+ if (input.weightLbs <= 150) return "MODERATE";
+ return "DIFFICULT";
+}
+
+// Per-profile component weights. Each row sums to ~1 across the 7 components.
+const PROFILE_WEIGHTS: Record<
+ ScoreProfileKey,
+ Partial<ScoreComponents> & { _confidence?: number }
+> = {
+ OVERALL_OPPORTUNITY: { arbitrage: 0.28, demand: 0.18, velocity: 0.14, logistics: 0.12, condition: 0.1, competition: 0.1, buyer: 0.08 },
+ BEST_ARBITRAGE: { arbitrage: 0.5, demand: 0.2, competition: 0.15, condition: 0.15 },
+ QUICK_FLIP: { velocity: 0.4, demand: 0.25, logistics: 0.2, arbitrage: 0.15 },
+ COLLECTOR: { demand: 0.4, condition: 0.35, arbitrage: 0.25 },
+ LOCAL_PICKUP: { arbitrage: 0.4, demand: 0.25, condition: 0.2, competition: 0.15 },
+ EASY_FREIGHT: { logistics: 0.45, arbitrage: 0.3, demand: 0.25 },
+ PARTS_ONLY: { arbitrage: 0.55, demand: 0.25, competition: 0.2 },
+ HIGH_CONFIDENCE: { arbitrage: 0.3, demand: 0.2, condition: 0.2, buyer: 0.15, velocity: 0.15 },
+ HIGH_PROFIT: { arbitrage: 0.45, demand: 0.3, velocity: 0.15, logistics: 0.1 },
+};
+
+const PROFILE_LABEL: Record<ScoreProfileKey, string> = {
+ OVERALL_OPPORTUNITY: "Overall Opportunity",
+ BEST_ARBITRAGE: "Best Arbitrage",
+ QUICK_FLIP: "Quick Flip",
+ COLLECTOR: "Collector",
+ LOCAL_PICKUP: "Local Pickup",
+ EASY_FREIGHT: "Easy Freight",
+ PARTS_ONLY: "Parts Only",
+ HIGH_CONFIDENCE: "High Confidence",
+ HIGH_PROFIT: "High Profit",
+};
+
+const COMPONENT_LABEL: Record<keyof ScoreComponents, string> = {
+ arbitrage: "arbitrage margin",
+ demand: "market demand",
+ velocity: "sale velocity",
+ logistics: "logistics ease",
+ condition: "item condition",
+ competition: "low competition",
+ buyer: "buyer readiness",
+};
+
+function scoreProfile(
+ profile: ScoreProfileKey,
+ c: ScoreComponents,
+ input: ScoreInput,
+ risk: RiskKey,
+ dropShip: DropShipKey,
+): ProfileScore {
+ const weights = PROFILE_WEIGHTS[profile];
+ const factors: ProfileScore["factors"] = [];
+ let value = 0;
+
+ for (const [key, w] of Object.entries(weights) as [keyof ScoreComponents, number][]) {
+ if (key === ("_confidence" as keyof ScoreComponents)) continue;
+ const comp = c[key] ?? 0;
+ const contribution = round2(comp * w);
+ value += contribution;
+ factors.push({ label: COMPONENT_LABEL[key], weight: w, contribution });
+ }
+
+ // Profile-specific adjustments beyond the weighted base.
+ let adjustment = 0;
+ const notes: string[] = [];
+ if (profile === "HIGH_CONFIDENCE") {
+ adjustment = (input.confidenceScore - 50) * 0.3;
+ notes.push(`confidence ${input.confidenceScore.toFixed(0)}/100`);
+ }
+ if (profile === "PARTS_ONLY" && input.condition !== "FOR_PARTS") {
+ adjustment -= 20; // parts-only profile penalizes non-parts items
+ notes.push("not a parts/salvage lot");
+ }
+ if (profile === "PARTS_ONLY" && input.condition === "FOR_PARTS") {
+ adjustment += 12;
+ notes.push("genuine salvage lot");
+ }
+ if (profile === "LOCAL_PICKUP") {
+ adjustment += input.localPickup ? 8 : -12;
+ notes.push(input.localPickup ? "local pickup available" : "no local pickup");
+ }
+ if (profile === "EASY_FREIGHT" && input.isFreight) {
+ adjustment -= 15;
+ notes.push("ships as LTL freight");
+ }
+ if (profile === "HIGH_PROFIT") {
+ // Reward large absolute dollars, not just ratio.
+ adjustment += clamp(input.expectedNetProfit / 250, -10, 20);
+ notes.push(`${formatMoney(input.expectedNetProfit)} est. net`);
+ }
+
+ value = round2(clamp(value + adjustment, 0, 100));
+
+ // Build the "why" explanation from the top contributors.
+ const top = [...factors].sort((a, b) => b.contribution - a.contribution).slice(0, 3);
+ const drivers = top
+ .map((f) => `${f.label} (${f.contribution.toFixed(0)} pts)`)
+ .join(", ");
+ const roiPhrase = `ROI ${formatPercent(input.roi)}`;
+ const extra = notes.length ? ` Adjustments: ${notes.join("; ")}.` : "";
+ const explanation =
+ `${PROFILE_LABEL[profile]} scored ${value.toFixed(0)}/100. ` +
+ `Top drivers: ${drivers}. ${roiPhrase}, ${input.daysUntilSold}d to sell, ` +
+ `risk ${risk}, drop-ship ${dropShip}.${extra}`;
+
+ return { profile, value, components: c, risk, dropShip, explanation, factors };
+}
+
+/** Compute all nine profile scores for a listing. */
+export function computeScores(input: ScoreInput): ProfileScore[] {
+ const c = computeComponents(input);
+ const risk = computeRisk(input, c);
+ const dropShip = computeDropShip(input);
+ return (Object.keys(PROFILE_WEIGHTS) as ScoreProfileKey[]).map((p) =>
+ scoreProfile(p, c, input, risk, dropShip),
+ );
+}
diff --git a/src/engines/types.ts b/src/engines/types.ts
new file mode 100644
index 0000000..e9b95c5
--- /dev/null
+++ b/src/engines/types.ts
@@ -0,0 +1,133 @@
+// Shared engine types. Engines operate on plain numbers (US dollars) so they
+// are pure, deterministic, and unit-testable independent of Prisma/Decimal.
+
+export type ConditionKey =
+ | "NEW"
+ | "LIKE_NEW"
+ | "USED_GOOD"
+ | "USED_FAIR"
+ | "FOR_PARTS"
+ | "UNKNOWN";
+
+export type SourceKey =
+ | "GOVDEALS"
+ | "PUBLIC_SURPLUS"
+ | "GSA_AUCTIONS"
+ | "COUNTY"
+ | "STATE_SURPLUS"
+ | "UNIVERSITY_SURPLUS"
+ | "MUNICIBID"
+ | "BID4ASSETS"
+ | "CSV"
+ | "EXTENSION"
+ | "OTHER";
+
+export type RiskKey = "LOW" | "MEDIUM" | "HIGH";
+export type DropShipKey = "EASY" | "MODERATE" | "DIFFICULT" | "INFEASIBLE";
+
+export type ScoreProfileKey =
+ | "OVERALL_OPPORTUNITY"
+ | "BEST_ARBITRAGE"
+ | "QUICK_FLIP"
+ | "COLLECTOR"
+ | "LOCAL_PICKUP"
+ | "EASY_FREIGHT"
+ | "PARTS_ONLY"
+ | "HIGH_CONFIDENCE"
+ | "HIGH_PROFIT";
+
+/** Inputs the valuation engine needs (anchor + item facts). */
+export interface ValuationInput {
+ /** Best estimate of new retail price; the anchor for all derived values. */
+ newRetail: number;
+ condition: ConditionKey;
+ quantity: number;
+ /** 0..100 demand for this category/brand (from research/AI or a default). */
+ demandScore: number;
+ /** How well the item was identified (0..1); drives confidence. */
+ identificationConfidence: number;
+ /** Count of real comparable sales found; drives confidence. */
+ comparableCount?: number;
+}
+
+export interface Valuation {
+ newRetail: number;
+ newReplacement: number;
+ avgRetail: number;
+ usedSoldPrice: number;
+ usedAskingPrice: number;
+ usedLow: number;
+ usedHigh: number;
+ wholesaleValue: number;
+ liquidationValue: number;
+ sellTodayValue: number;
+ value7Day: number;
+ value30Day: number;
+ value90Day: number;
+ expectedSalePrice: number;
+ probabilityOfSale: number; // 0..1
+ daysUntilSold: number;
+ confidenceScore: number; // 0..100
+}
+
+export interface CostInput {
+ source: SourceKey;
+ winningBid: number;
+ quantity: number;
+ weightLbs: number;
+ condition: ConditionKey;
+ expectedSalePrice: number;
+ daysUntilSold: number;
+ /** true when the item must be picked up in person (no shipping from seller). */
+ localPickup?: boolean;
+ /** destination sales-tax rate applied to the purchase (resale certs may zero this). */
+ purchaseTaxRate?: number;
+ /** repair estimate override (else derived from condition). */
+ repairsOverride?: number;
+}
+
+export interface CostBreakdown {
+ winningBid: number;
+ buyerPremium: number;
+ salesTax: number;
+ shipping: number;
+ freight: number;
+ insurance: number;
+ packing: number;
+ pickupLabor: number;
+ testing: number;
+ repairs: number;
+ certification: number;
+ marketplaceFees: number;
+ paymentFees: number;
+ storage: number;
+ photography: number;
+ listingLabor: number;
+ expectedReturns: number;
+ totalInvestment: number;
+ expectedNetProfit: number;
+ roi: number;
+ annualizedReturn: number;
+ recommendedMaxBid: number;
+ assumptions: Record<string, string | number>;
+}
+
+export interface ScoreComponents {
+ arbitrage: number;
+ demand: number;
+ velocity: number;
+ logistics: number;
+ condition: number;
+ competition: number;
+ buyer: number;
+}
+
+export interface ProfileScore {
+ profile: ScoreProfileKey;
+ value: number;
+ components: ScoreComponents;
+ risk: RiskKey;
+ dropShip: DropShipKey;
+ explanation: string;
+ factors: { label: string; weight: number; contribution: number }[];
+}
diff --git a/src/engines/valuation.test.ts b/src/engines/valuation.test.ts
new file mode 100644
index 0000000..a8af7ee
--- /dev/null
+++ b/src/engines/valuation.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { computeValuation } from "./valuation";
+
+describe("computeValuation", () => {
+ it("derives a full valuation ladder from a retail anchor", () => {
+ const v = computeValuation({
+ newRetail: 20000,
+ condition: "USED_GOOD",
+ quantity: 1,
+ demandScore: 70,
+ identificationConfidence: 0.9,
+ comparableCount: 6,
+ });
+
+ // Used-good sits at ~46% of retail.
+ expect(v.usedSoldPrice).toBeCloseTo(9200, 0);
+ expect(v.usedAskingPrice).toBeGreaterThan(v.usedSoldPrice);
+ expect(v.usedLow).toBeLessThan(v.usedSoldPrice);
+ expect(v.usedHigh).toBeGreaterThan(v.usedSoldPrice);
+ // Channel ordering: liquidation < wholesale < used.
+ expect(v.liquidationValue).toBeLessThan(v.wholesaleValue);
+ expect(v.wholesaleValue).toBeLessThan(v.usedSoldPrice);
+ // Longer horizon fetches more than a fire sale.
+ expect(v.value90Day).toBeGreaterThan(v.sellTodayValue);
+ expect(v.expectedSalePrice).toBeGreaterThan(0);
+ });
+
+ it("scales expected sale price by quantity", () => {
+ const one = computeValuation({
+ newRetail: 500,
+ condition: "LIKE_NEW",
+ quantity: 1,
+ demandScore: 50,
+ identificationConfidence: 0.6,
+ });
+ const ten = computeValuation({
+ newRetail: 500,
+ condition: "LIKE_NEW",
+ quantity: 10,
+ demandScore: 50,
+ identificationConfidence: 0.6,
+ });
+ expect(ten.expectedSalePrice).toBeCloseTo(one.expectedSalePrice * 10, 0);
+ });
+
+ it("gives higher confidence with more comparables and better ID", () => {
+ const weak = computeValuation({
+ newRetail: 1000,
+ condition: "UNKNOWN",
+ quantity: 1,
+ demandScore: 40,
+ identificationConfidence: 0.2,
+ comparableCount: 0,
+ });
+ const strong = computeValuation({
+ newRetail: 1000,
+ condition: "USED_GOOD",
+ quantity: 1,
+ demandScore: 40,
+ identificationConfidence: 0.95,
+ comparableCount: 8,
+ });
+ expect(strong.confidenceScore).toBeGreaterThan(weak.confidenceScore);
+ expect(strong.confidenceScore).toBeLessThanOrEqual(100);
+ });
+
+ it("rewards high demand with faster sale and higher probability", () => {
+ const lowDemand = computeValuation({
+ newRetail: 1000,
+ condition: "USED_GOOD",
+ quantity: 1,
+ demandScore: 10,
+ identificationConfidence: 0.7,
+ });
+ const highDemand = computeValuation({
+ newRetail: 1000,
+ condition: "USED_GOOD",
+ quantity: 1,
+ demandScore: 95,
+ identificationConfidence: 0.7,
+ });
+ expect(highDemand.daysUntilSold).toBeLessThan(lowDemand.daysUntilSold);
+ expect(highDemand.probabilityOfSale).toBeGreaterThan(lowDemand.probabilityOfSale);
+ });
+});
diff --git a/src/engines/valuation.ts b/src/engines/valuation.ts
new file mode 100644
index 0000000..1f80eaf
--- /dev/null
+++ b/src/engines/valuation.ts
@@ -0,0 +1,84 @@
+import { clamp, round2 } from "@/lib/utils";
+import { CONDITION_RETAIL_FACTOR } from "./constants";
+import type { Valuation, ValuationInput } from "./types";
+
+/**
+ * Derive the full valuation ladder from a single retail anchor plus item facts.
+ *
+ * The AI/research layer supplies the hard-to-know inputs (a `newRetail` anchor,
+ * a `demandScore`, identification confidence, comparable count); everything
+ * else is derived deterministically here so the numbers are reproducible and
+ * explainable rather than a black box.
+ */
+export function computeValuation(input: ValuationInput): Valuation {
+ const qty = Math.max(1, input.quantity || 1);
+ const perUnitRetail = Math.max(0, input.newRetail);
+ const condFactor = CONDITION_RETAIL_FACTOR[input.condition] ?? 0.35;
+ const demand = clamp(input.demandScore ?? 50, 0, 100);
+
+ // Per-unit retail figures.
+ const newRetail = perUnitRetail;
+ const newReplacement = round2(perUnitRetail * 1.05); // like-for-like buy-new cost
+ const avgRetail = round2(perUnitRetail * 0.92); // street average vs MSRP
+
+ // Used market, anchored off condition.
+ const usedSoldPrice = round2(perUnitRetail * condFactor);
+ const usedAskingPrice = round2(usedSoldPrice * 1.25);
+ const usedLow = round2(usedSoldPrice * 0.8);
+ const usedHigh = round2(usedSoldPrice * 1.3);
+
+ // Channel values (per unit).
+ const wholesaleValue = round2(usedSoldPrice * 0.6);
+ const liquidationValue = round2(usedSoldPrice * 0.42);
+ const sellTodayValue = round2(liquidationValue * 1.1); // fire-sale, today
+
+ // Time-horizon values: the longer you wait, the closer to asking you get,
+ // scaled by how much demand there is.
+ const patience = 0.5 + demand / 200; // 0.5..1.0
+ const value7Day = round2(usedSoldPrice * (0.8 + 0.1 * patience));
+ const value30Day = round2(usedSoldPrice * (0.95 + 0.1 * patience));
+ const value90Day = round2(usedAskingPrice * (0.85 + 0.1 * patience));
+
+ // Expected sale price: demand-weighted blend across horizons.
+ const expectedSalePrice = round2(
+ value7Day * 0.2 + value30Day * 0.5 + value90Day * 0.3,
+ );
+
+ // Probability of sale within 90 days, driven by demand + condition quality.
+ const probabilityOfSale = round2(
+ clamp(0.35 + demand / 200 + (condFactor - 0.35) * 0.4, 0.1, 0.98),
+ );
+
+ // Days until sold: high demand sells fast; low demand lingers.
+ const daysUntilSold = Math.round(clamp(90 - demand * 0.75, 5, 120));
+
+ // Confidence: how much do we trust these numbers? Built from identification
+ // confidence and the number of real comparables backing the estimate.
+ const idConf = clamp(input.identificationConfidence ?? 0.5, 0, 1);
+ const comps = input.comparableCount ?? 0;
+ const confidenceScore = round2(
+ clamp(idConf * 60 + Math.min(comps, 8) * 5, 0, 100),
+ );
+
+ // Return per-unit valuations but scale the aggregate resale-relevant figure
+ // (expectedSalePrice) by quantity, since a lot of N sells N units.
+ return {
+ newRetail,
+ newReplacement,
+ avgRetail,
+ usedSoldPrice,
+ usedAskingPrice,
+ usedLow,
+ usedHigh,
+ wholesaleValue,
+ liquidationValue,
+ sellTodayValue,
+ value7Day,
+ value30Day,
+ value90Day,
+ expectedSalePrice: round2(expectedSalePrice * qty),
+ probabilityOfSale,
+ daysUntilSold,
+ confidenceScore,
+ };
+}
diff --git a/src/importers/apify-govdeals.ts b/src/importers/apify-govdeals.ts
new file mode 100644
index 0000000..e4fc9ef
--- /dev/null
+++ b/src/importers/apify-govdeals.ts
@@ -0,0 +1,148 @@
+import type { RawListing } from "./ingest";
+
+// GovDeals via the Apify actor `parseforge/govdeals-scraper` — bypasses GovDeals'
+// Akamai bot-wall (Apify handles it). We READ finished datasets (free); running
+// the actor for FRESH data costs Apify compute (~$0.0037/listing) and is gated.
+//
+// Env: APIFY_TOKEN (required). Optionally APIFY_GOVDEALS_ACTOR (default the known
+// actor) and APIFY_GOVDEALS_DATASET (a specific dataset id to read).
+
+const API = "https://api.apify.com/v2";
+const DEFAULT_ACTOR = process.env.APIFY_GOVDEALS_ACTOR || "SZdBCF9FpwOzvByvM"; // parseforge/govdeals-scraper
+
+interface ApifyItem {
+ title?: string;
+ url?: string;
+ imageUrl?: string;
+ photos?: (string | { url?: string })[];
+ accountId?: number;
+ assetId?: number;
+ auctionId?: number;
+ category?: string;
+ parentCategory?: string;
+ make?: string | null;
+ model?: string | null;
+ condition?: string | null;
+ quantity?: number;
+ weight?: number | null;
+ description?: string;
+ locationCity?: string;
+ locationState?: string;
+ locationZip?: string;
+ currentBid?: number;
+ bidCount?: number;
+ auctionEnd?: string;
+ auctionEndUtc?: string;
+ specialInstructions?: string;
+ removalInstructions?: string;
+ paymentInstructions?: string;
+}
+
+const CONDITION_MAP: Record<string, RawListing["condition"]> = {
+ N: "NEW",
+ U: "USED_GOOD",
+ R: "LIKE_NEW",
+ F: "USED_FAIR",
+ S: "FOR_PARTS",
+ P: "FOR_PARTS",
+};
+
+const stripHtml = (s?: string) =>
+ (s || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim() || undefined;
+
+function photoUrls(item: ApifyItem): string[] {
+ if (Array.isArray(item.photos) && item.photos.length) {
+ return item.photos.map((p) => (typeof p === "string" ? p : p?.url || "")).filter(Boolean).slice(0, 8);
+ }
+ return item.imageUrl ? [item.imageUrl] : [];
+}
+
+function mapItem(it: ApifyItem): RawListing | null {
+ const title = it.title?.trim();
+ if (!title || it.assetId == null) return null;
+ return {
+ source: "GOVDEALS",
+ sourceAuctionId: `${it.accountId ?? "x"}-${it.assetId}`,
+ sourceUrl: it.url || "https://www.govdeals.com",
+ title,
+ description: stripHtml(it.description),
+ category: it.category || it.parentCategory || undefined,
+ manufacturer: it.make || undefined,
+ model: it.model || undefined,
+ condition: CONDITION_MAP[(it.condition || "").trim().toUpperCase()] || "UNKNOWN",
+ quantity: it.quantity && it.quantity > 0 ? it.quantity : 1,
+ weightLbs: it.weight ?? undefined,
+ locationCity: it.locationCity,
+ locationState: it.locationState,
+ locationZip: it.locationZip,
+ currentBid: it.currentBid ?? 0,
+ bidCount: it.bidCount ?? 0,
+ closingAt: it.auctionEndUtc || it.auctionEnd || null,
+ imageUrls: photoUrls(it),
+ auctionTerms: [it.specialInstructions, it.removalInstructions, it.paymentInstructions]
+ .map(stripHtml)
+ .filter(Boolean)
+ .join(" ") || undefined,
+ };
+}
+
+async function apifyJson(path: string): Promise<unknown> {
+ const token = process.env.APIFY_TOKEN;
+ if (!token) throw new Error("APIFY_TOKEN not set");
+ const sep = path.includes("?") ? "&" : "?";
+ const res = await fetch(`${API}${path}${sep}token=${token}&clean=true`);
+ if (!res.ok) throw new Error(`Apify ${res.status}: ${(await res.text()).slice(0, 160)}`);
+ return res.json();
+}
+
+/** Read an already-scraped dataset by id (free). */
+export async function fetchApifyDataset(datasetId: string, limit = 200): Promise<RawListing[]> {
+ const items = (await apifyJson(`/datasets/${datasetId}/items?limit=${limit}`)) as ApifyItem[];
+ return items.map(mapItem).filter((x): x is RawListing => x !== null);
+}
+
+/** Read the most recent SUCCEEDED run's dataset for the GovDeals actor (free). */
+export async function fetchApifyLastRun(limit = 200): Promise<RawListing[]> {
+ const items = (await apifyJson(
+ `/acts/${DEFAULT_ACTOR}/runs/last/dataset/items?status=SUCCEEDED&limit=${limit}`,
+ )) as ApifyItem[];
+ return items.map(mapItem).filter((x): x is RawListing => x !== null);
+}
+
+/**
+ * Trigger a NEW GovDeals scrape run (PAID — ~$0.0037/listing) and return the
+ * scraped rows plus the run's actual USD cost. Steve-authorized for the daily
+ * 100-listing job. Polls up to ~8 min for completion.
+ */
+export async function triggerApifyRun(
+ maxItems = 100,
+): Promise<{ rows: RawListing[]; costUsd: number; runId: string }> {
+ const token = process.env.APIFY_TOKEN;
+ if (!token) throw new Error("APIFY_TOKEN not set");
+
+ const startRes = await fetch(`${API}/acts/${DEFAULT_ACTOR}/runs?token=${token}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ maxItems, sortOrder: "asc" }),
+ });
+ if (!startRes.ok) throw new Error(`Apify run start ${startRes.status}: ${(await startRes.text()).slice(0, 160)}`);
+ const run = ((await startRes.json()) as { data: { id: string; status: string; defaultDatasetId: string } }).data;
+
+ const deadline = Date.now() + 8 * 60 * 1000;
+ let status = run.status;
+ let datasetId = run.defaultDatasetId;
+ let costUsd = 0;
+ while (Date.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 5000));
+ const res = await fetch(`${API}/actor-runs/${run.id}?token=${token}`);
+ const s = ((await res.json()) as { data: { status: string; defaultDatasetId: string; usageTotalUsd?: number } }).data;
+ status = s.status;
+ datasetId = s.defaultDatasetId;
+ costUsd = s.usageTotalUsd ?? 0;
+ if (["SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"].includes(status)) break;
+ }
+ if (status !== "SUCCEEDED") throw new Error(`Apify run ${run.id} ended ${status}`);
+
+ const rows = await fetchApifyDataset(datasetId, maxItems);
+ return { rows, costUsd, runId: run.id };
+}
diff --git a/src/importers/csv.ts b/src/importers/csv.ts
new file mode 100644
index 0000000..5e255fe
--- /dev/null
+++ b/src/importers/csv.ts
@@ -0,0 +1,71 @@
+import { parse } from "csv-parse/sync";
+import type { RawListing } from "./ingest";
+
+// Column aliases → RawListing fields, so operators can paste varied CSVs.
+const FIELD_ALIASES: Record<string, keyof RawListing> = {
+ source: "source",
+ auction: "sourceAuctionId",
+ "auction #": "sourceAuctionId",
+ auction_id: "sourceAuctionId",
+ sourceauctionid: "sourceAuctionId",
+ url: "sourceUrl",
+ link: "sourceUrl",
+ title: "title",
+ name: "title",
+ description: "description",
+ desc: "description",
+ category: "category",
+ manufacturer: "manufacturer",
+ mfr: "manufacturer",
+ brand: "manufacturer",
+ model: "model",
+ condition: "condition",
+ quantity: "quantity",
+ qty: "quantity",
+ weight: "weightLbs",
+ weightlbs: "weightLbs",
+ dimensions: "dimensions",
+ city: "locationCity",
+ state: "locationState",
+ zip: "locationZip",
+ bid: "currentBid",
+ currentbid: "currentBid",
+ "current bid": "currentBid",
+ bidcount: "bidCount",
+ bids: "bidCount",
+ closing: "closingAt",
+ closingat: "closingAt",
+ "closing date": "closingAt",
+ images: "imageUrls",
+ imageurls: "imageUrls",
+ terms: "auctionTerms",
+};
+
+const NUMERIC: (keyof RawListing)[] = ["quantity", "weightLbs", "currentBid", "bidCount"];
+
+/** Parse a CSV string into normalized RawListing rows (header-driven, alias-aware). */
+export function parseListingsCsv(text: string): RawListing[] {
+ const records = parse(text, {
+ columns: (header: string[]) => header.map((h) => h.trim().toLowerCase()),
+ skip_empty_lines: true,
+ trim: true,
+ relax_column_count: true,
+ }) as Record<string, string>[];
+
+ return records.map((rec) => {
+ const raw: Record<string, unknown> = {};
+ for (const [key, value] of Object.entries(rec)) {
+ const field = FIELD_ALIASES[key];
+ if (!field || value === "" || value == null) continue;
+ if (NUMERIC.includes(field)) {
+ const num = Number(String(value).replace(/[$,]/g, ""));
+ if (!Number.isNaN(num)) raw[field] = num;
+ } else if (field === "imageUrls") {
+ raw[field] = String(value).split(/[|;]/).map((s) => s.trim()).filter(Boolean);
+ } else {
+ raw[field] = value;
+ }
+ }
+ return raw as unknown as RawListing;
+ });
+}
diff --git a/src/importers/govdeals-free.ts b/src/importers/govdeals-free.ts
new file mode 100644
index 0000000..ef95144
--- /dev/null
+++ b/src/importers/govdeals-free.ts
@@ -0,0 +1,127 @@
+import type { RawListing } from "./ingest";
+
+// FREE GovDeals importer — hits GovDeals' own backend search API directly
+// (maestro.lqdt1.com/search/list), the same call the site's Angular app makes.
+// No Apify, no scraping, no login, $0. The x-api-key is GovDeals' PUBLIC client
+// key (shipped in their public JS bundle), overridable via GOVDEALS_MAESTRO_KEY.
+//
+// The same endpoint serves sibling Liquidity Services marketplaces by businessId:
+// GD = GovDeals · GI = GoIndustry DoveBid · NI = Network Int'l.
+
+const MAESTRO = "https://maestro.lqdt1.com/search/list";
+const API_KEY = process.env.GOVDEALS_MAESTRO_KEY || "af93060f-337e-428c-87b8-c74b5837d6cd";
+const PHOTO_BASE = "https://webassets.lqdt1.com/assets/photos";
+
+interface Asset {
+ accountId?: number;
+ assetId?: number;
+ assetShortDescription?: string;
+ assetLongDescription?: string | null;
+ makebrand?: string | null;
+ model?: string | null;
+ modelYear?: string | null;
+ categoryDescription?: string | null;
+ currentBid?: number | null;
+ bidCount?: number | null;
+ assetAuctionEndDate?: string | null;
+ locationCity?: string;
+ locationState?: string;
+ locationZip?: string;
+ photo?: string | null;
+}
+
+const uuid = () => globalThis.crypto.randomUUID();
+
+// Liquidity Services marketplaces served by the same maestro API, keyed by businessId.
+export const MARKETS: Record<string, { source: string; base: string }> = {
+ GD: { source: "GOVDEALS", base: "https://www.govdeals.com" },
+ GI: { source: "GOINDUSTRY", base: "https://www.go-dove.com" },
+ NI: { source: "NETWORK_INTL", base: "https://www.networkintl.com" },
+};
+
+function mapAsset(a: Asset, market: { source: string; base: string }): RawListing | null {
+ if (!a.assetShortDescription || a.assetId == null) return null;
+ return {
+ source: market.source,
+ sourceAuctionId: `${a.accountId ?? "x"}-${a.assetId}`,
+ sourceUrl: `${market.base}/en/asset/${a.accountId}/${a.assetId}`,
+ title: a.assetShortDescription.trim(),
+ description: a.assetLongDescription || undefined,
+ category: a.categoryDescription || undefined,
+ manufacturer: a.makebrand || undefined,
+ model: a.model || undefined,
+ condition: "UNKNOWN",
+ quantity: 1,
+ locationCity: a.locationCity,
+ locationState: a.locationState,
+ locationZip: a.locationZip,
+ currentBid: Number(a.currentBid) || 0,
+ bidCount: Number(a.bidCount) || 0,
+ closingAt: a.assetAuctionEndDate || null,
+ imageUrls: a.photo ? [`${PHOTO_BASE}/${a.accountId}/${a.photo}`] : [],
+ };
+}
+
+async function fetchPage(businessId: string, page: number, displayRows: number): Promise<Asset[]> {
+ const body = {
+ categoryIds: "",
+ businessId,
+ searchText: "*",
+ isQAL: false,
+ locationId: null,
+ model: "",
+ makebrand: "",
+ auctionTypeId: null,
+ page,
+ displayRows,
+ sortField: "bestfit",
+ sortOrder: "",
+ sessionId: uuid(),
+ requestType: "search",
+ responseStyle: "fullResponse",
+ facets: [
+ "categoryName", "auctionTypeID", "condition", "saleEventName", "sellerDisplayName",
+ "product_pricecents", "isReserveMet", "hasBuyNowPrice", "isReserveNotMet", "sellerType",
+ "warehouseId", "region", "currencyTypeCode", "tierId",
+ ],
+ facetsFilter: [],
+ timeType: "newListings",
+ sellerTypeId: null,
+ accountIds: [],
+ };
+ const res = await fetch(MAESTRO, {
+ method: "POST",
+ headers: {
+ "x-api-key": API_KEY,
+ "Content-Type": "application/json",
+ audience: "Ecom",
+ "x-user-id": "-1",
+ "x-api-correlation-id": uuid(),
+ },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(`maestro ${res.status}: ${(await res.text()).slice(0, 160)}`);
+ const j = (await res.json()) as { assetSearchResults?: Asset[] };
+ return j.assetSearchResults ?? [];
+}
+
+/** Fetch newest GovDeals (or GI/NI) listings, paging until `limit`. Free, $0. */
+export async function fetchGovdealsFree(
+ opts: { limit?: number; businessId?: string } = {},
+): Promise<RawListing[]> {
+ const businessId = (opts.businessId || "GD").toUpperCase();
+ const market = MARKETS[businessId] ?? MARKETS.GD;
+ const limit = opts.limit ?? 120;
+ const perPage = 120;
+ const out: RawListing[] = [];
+ for (let page = 1; out.length < limit && page <= 20; page++) {
+ const assets = await fetchPage(businessId, page, perPage);
+ if (!assets.length) break;
+ for (const a of assets) {
+ const m = mapAsset(a, market);
+ if (m) out.push(m);
+ }
+ if (assets.length < perPage) break;
+ }
+ return out.slice(0, limit);
+}
diff --git a/src/importers/govplanet-free.ts b/src/importers/govplanet-free.ts
new file mode 100644
index 0000000..a9bf76f
--- /dev/null
+++ b/src/importers/govplanet-free.ts
@@ -0,0 +1,227 @@
+import type { RawListing } from "./ingest";
+
+// FREE GovPlanet importer. $0, no browser at runtime.
+//
+// GovPlanet runs the IronPlanet / Ritchie Bros commerce stack (Java, `.ips`
+// URLs). It is NOT on the Liquidity Services `maestro.lqdt1.com` API (verified:
+// businessIds GP/GOVPLANET/IP/IRONPLANET all return 0 results). Its category
+// landing pages are JS-hydrated, BUT the item search page embeds a per-item
+// `quickviews.push({...})` JSON object with every field we need — served to
+// plain curl (HTTP 200, no bot wall). So the crack is: fetch the search HTML,
+// extract each quickviews object with a brace/string-aware scanner, JSON.parse.
+//
+// Verified 2026-07-10. Endpoints (each returns ~60 items):
+// https://www.govplanet.com/buy-now
+// https://www.govplanet.com/searchResults.ips?keyword=<kw> (empty kw = all)
+// quickviews object fields:
+// equipId, description (title), price/convPrice (USD), currency ("USD"),
+// eumeLocation, itemPageUri, photoThumb, timeLeft (relative), bidCnt, features
+// Prices are already USD (US government surplus) — no FX conversion needed.
+// Native pagination is AJAX-only, so we broaden coverage across the two base
+// endpoints plus a set of high-value category keywords, deduped by equipId.
+
+const BASE = "https://www.govplanet.com";
+const UA =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+
+// High-value US-resale categories to broaden coverage beyond the ~60-item
+// default page (GovPlanet's on-page pagination is AJAX-only).
+const KEYWORDS = [
+ "", // all
+ "generator",
+ "truck",
+ "trailer",
+ "forklift",
+ "excavator",
+ "loader",
+ "trailer",
+ "tractor",
+ "compressor",
+ "welder",
+ "hmmwv",
+ "humvee",
+];
+
+function stripTags(s: string): string {
+ return s
+ .replace(/<[^>]*>/g, " ")
+ .replace(/&/g, "&")
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/ /g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function toNumber(s: string | undefined): number {
+ if (!s) return 0;
+ const m = /([\d,]+(?:\.\d+)?)/.exec(s.replace(/[^0-9.,]/g, ""));
+ return m ? Number(m[1].replace(/,/g, "")) : 0;
+}
+
+// Parse a relative "timeLeft" like "2 days", "4 hrs", "15 mins" -> ISO close.
+// GovPlanet often renders " " (blank) for offer-only items -> null.
+function parseRelativeClose(raw: string | undefined): string | null {
+ if (!raw) return null;
+ const txt = stripTags(raw).toLowerCase();
+ if (!txt || /ended|closed/.test(txt)) return null;
+ const units: [RegExp, number][] = [
+ [/(\d+)\s*day/, 86400_000],
+ [/(\d+)\s*h(?:ou)?r/, 3600_000],
+ [/(\d+)\s*min/, 60_000],
+ [/(\d+)\s*sec/, 1000],
+ ];
+ let deltaMs = 0;
+ let matched = false;
+ for (const [re, ms] of units) {
+ const m = re.exec(txt);
+ if (m) {
+ deltaMs += Number(m[1]) * ms;
+ matched = true;
+ }
+ }
+ if (!matched) return null;
+ return new Date(Date.now() + deltaMs).toISOString();
+}
+
+/**
+ * Extract every `quickviews.push({...})` object from a GovPlanet search page.
+ * Uses a string/brace-aware scanner because values contain unescaped `)` and
+ * `{` inside strings (e.g. "(2,424 mi away)"), which a greedy regex would break.
+ */
+function extractQuickviews(html: string): Record<string, unknown>[] {
+ const out: Record<string, unknown>[] = [];
+ const marker = "quickviews.push(";
+ let idx = html.indexOf(marker);
+ while (idx !== -1) {
+ let i = idx + marker.length;
+ // Expect the object to start at '{'.
+ while (i < html.length && html[i] !== "{") i++;
+ if (i >= html.length) break;
+ const start = i;
+ let depth = 0;
+ let inStr = false;
+ let quote = "";
+ for (; i < html.length; i++) {
+ const ch = html[i];
+ if (inStr) {
+ if (ch === "\\") {
+ i++; // skip escaped char
+ continue;
+ }
+ if (ch === quote) inStr = false;
+ } else {
+ if (ch === '"' || ch === "'") {
+ inStr = true;
+ quote = ch;
+ } else if (ch === "{") depth++;
+ else if (ch === "}") {
+ depth--;
+ if (depth === 0) {
+ i++;
+ break;
+ }
+ }
+ }
+ }
+ const objStr = html.slice(start, i);
+ try {
+ out.push(JSON.parse(objStr));
+ } catch {
+ // Some pages use single-quoted values in a few fields; best-effort skip.
+ }
+ idx = html.indexOf(marker, i);
+ }
+ return out;
+}
+
+function qvToListing(qv: Record<string, unknown>): RawListing | null {
+ const id = String(qv.equipId || qv.itemId || "").trim();
+ const title = stripTags(String(qv.description || "")).trim();
+ if (!id || !title) return null;
+
+ const priceHtml = String(qv.price || qv.convPrice || "");
+ const currentBid = toNumber(priceHtml);
+
+ const uri = String(qv.itemPageUri || `/item/${id}`);
+ const sourceUrl = uri.startsWith("http") ? uri : `${BASE}${uri}`;
+
+ const state = String(qv.eumeLocation || "").trim() || undefined;
+ const img = qv.photoThumb ? String(qv.photoThumb) : undefined;
+ const bidCount = toNumber(String(qv.bidCnt || "")) || 0;
+ const closingAt = parseRelativeClose(qv.timeLeft ? String(qv.timeLeft) : undefined);
+ const features = stripTags(String(qv.features || "")) || undefined;
+
+ return {
+ source: "GOVPLANET",
+ sourceAuctionId: id,
+ sourceUrl,
+ title,
+ description: features,
+ locationState: state,
+ currentBid,
+ bidCount,
+ closingAt,
+ imageUrls: img ? [img] : [],
+ };
+}
+
+async function fetchSearch(keyword: string): Promise<string> {
+ const url = keyword
+ ? `${BASE}/searchResults.ips?keyword=${encodeURIComponent(keyword)}`
+ : `${BASE}/buy-now`;
+ const res = await fetch(url, {
+ headers: {
+ "User-Agent": UA,
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "en-US,en;q=0.9",
+ },
+ });
+ if (!res.ok) throw new Error(`GovPlanet "${keyword || "buy-now"}" -> HTTP ${res.status}`);
+ return res.text();
+}
+
+export interface FetchGovPlanetOpts {
+ limit?: number;
+}
+
+/**
+ * Fetch up to `limit` active GovPlanet items by scanning the buy-now page and a
+ * set of high-value category keyword searches, deduped by equipId. All prices
+ * are USD. $0 — plain HTTPS, no browser, no API key.
+ */
+export async function fetchGovPlanetFree(
+ opts: FetchGovPlanetOpts = {},
+): Promise<RawListing[]> {
+ const limit = opts.limit ?? 100;
+ const seen = new Set<string>();
+ const rows: RawListing[] = [];
+
+ // Always start with the dedicated buy-now page, then keyword searches.
+ const queries = ["", ...KEYWORDS];
+ const uniqueQueries = [...new Set(queries)];
+
+ for (const kw of uniqueQueries) {
+ if (rows.length >= limit) break;
+ let html: string;
+ try {
+ html = await fetchSearch(kw);
+ } catch (e) {
+ console.warn(`[govplanet-free] ${(e as Error).message}`);
+ continue;
+ }
+ const qvs = extractQuickviews(html);
+ for (const qv of qvs) {
+ const listing = qvToListing(qv);
+ if (!listing) continue;
+ if (seen.has(listing.sourceAuctionId)) continue;
+ seen.add(listing.sourceAuctionId);
+ rows.push(listing);
+ if (rows.length >= limit) break;
+ }
+ await new Promise((r) => setTimeout(r, 350));
+ }
+
+ return rows.slice(0, limit);
+}
diff --git a/src/importers/grays-free.ts b/src/importers/grays-free.ts
new file mode 100644
index 0000000..67cc4fd
--- /dev/null
+++ b/src/importers/grays-free.ts
@@ -0,0 +1,88 @@
+import type { RawListing } from "./ingest";
+
+// FREE GraysOnline (Australia) importer via its public Algolia search index —
+// harvested App-Id + search key from the site's client (feed-first). $0, no
+// scraping. Grays prices are AUD; converted to USD so the valuation engine stays
+// coherent (rough fixed rate — refine with a live FX feed if needed).
+
+const APP_ID = process.env.GRAYS_ALGOLIA_APP_ID || "CKPAMVUUBE";
+const API_KEY = process.env.GRAYS_ALGOLIA_KEY || "1a31357659d5c40f4641cc0d46c172d0";
+const INDEX = "GOL_MAIN";
+const AUD_TO_USD = Number(process.env.AUD_USD || 0.66);
+
+interface Hit {
+ objectID?: string;
+ ObjectTitle?: string;
+ ObjectType?: string;
+ Category?: string;
+ ParentCategory?: string;
+ CONDITION?: string;
+ UnitQuantity?: number;
+ ObjectPrice?: number;
+ BidActionCount?: number;
+ LOT_DEFAULT_END_TIME?: number;
+ LOT_SKU?: string;
+ LOT_ID?: number;
+ ITEM_URL?: string;
+ ImageURLS?: string[];
+ SaleSuburb?: string;
+ SaleState?: string;
+}
+
+function mapHit(h: Hit): RawListing | null {
+ if (!h.ObjectTitle || !(h.LOT_SKU || h.LOT_ID)) return null;
+ const closing = h.LOT_DEFAULT_END_TIME ? new Date(h.LOT_DEFAULT_END_TIME * 1000).toISOString() : null;
+ return {
+ source: "GRAYS_AU",
+ sourceAuctionId: String(h.LOT_SKU || h.LOT_ID),
+ sourceUrl: h.ITEM_URL ? `https://www.grays.com${h.ITEM_URL}` : "https://www.grays.com",
+ title: h.ObjectTitle.trim(),
+ category: h.Category || h.ParentCategory || undefined,
+ condition: (h.CONDITION || "").toUpperCase().includes("NEW") ? "NEW" : "UNKNOWN",
+ quantity: h.UnitQuantity && h.UnitQuantity > 0 ? Math.floor(h.UnitQuantity) : 1,
+ locationCity: h.SaleSuburb,
+ locationState: h.SaleState,
+ // AUD → USD so downstream USD math is coherent.
+ currentBid: h.ObjectPrice ? Math.round(h.ObjectPrice * AUD_TO_USD * 100) / 100 : 0,
+ bidCount: h.BidActionCount ?? 0,
+ closingAt: closing,
+ imageUrls: Array.isArray(h.ImageURLS) ? h.ImageURLS.slice(0, 6) : [],
+ auctionTerms: "GraysOnline (AU). Prices shown converted AUD→USD; freight from Australia applies.",
+ };
+}
+
+/** Fetch newest Grays lots via Algolia, paging until `limit`. Free, $0. */
+export async function fetchGraysFree(opts: { limit?: number } = {}): Promise<RawListing[]> {
+ const limit = opts.limit ?? 100;
+ const perPage = 100;
+ const out: RawListing[] = [];
+ for (let page = 0; out.length < limit && page < 20; page++) {
+ const res = await fetch(`https://${APP_ID}-dsn.algolia.net/1/indexes/*/queries`, {
+ method: "POST",
+ headers: {
+ "X-Algolia-Application-Id": APP_ID,
+ "X-Algolia-API-Key": API_KEY,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ requests: [
+ {
+ indexName: INDEX,
+ query: "",
+ params: `hitsPerPage=${perPage}&page=${page}&filters=${encodeURIComponent("ObjectType:LOT OR ObjectType:RETAIL")}`,
+ },
+ ],
+ }),
+ });
+ if (!res.ok) throw new Error(`Grays Algolia ${res.status}: ${(await res.text()).slice(0, 160)}`);
+ const j = (await res.json()) as { results?: { hits?: Hit[] }[] };
+ const hits = j.results?.[0]?.hits ?? [];
+ if (!hits.length) break;
+ for (const h of hits) {
+ const m = mapHit(h);
+ if (m) out.push(m);
+ }
+ if (hits.length < perPage) break;
+ }
+ return out.slice(0, limit);
+}
diff --git a/src/importers/gsa.ts b/src/importers/gsa.ts
new file mode 100644
index 0000000..7e3c490
--- /dev/null
+++ b/src/importers/gsa.ts
@@ -0,0 +1,91 @@
+import type { RawListing } from "./ingest";
+
+// Official GSA Auctions API — free, JSON, no scraping/bot-wall. Federal surplus
+// auction listings from all participating agencies.
+// GET https://api.gsa.gov/assets/gsaauctions/v2/auctions (X-API-KEY header)
+// The endpoint 303-redirects to a pre-generated active-auctions.json on S3;
+// fetch() follows the redirect automatically.
+//
+// GSA_API_KEY defaults to the public DEMO_KEY (rate-limited). Supply an
+// api.data.gov key for production volume.
+
+const GSA_URL = "https://api.gsa.gov/assets/gsaauctions/v2/auctions";
+
+// The API returns lowercase-first field names; type them loosely + read defensively.
+interface GsaRow {
+ saleNo?: string;
+ lotNo?: string;
+ itemName?: string;
+ itemDescURL?: string;
+ lotInfo?: string;
+ aucStartDt?: string;
+ aucEndDt?: string;
+ auctionStatus?: string;
+ highBidAmount?: string | number;
+ reserve?: string | number;
+ aucIncrement?: string | number;
+ biddersCount?: string | number;
+ propertyCity?: string;
+ propertyState?: string;
+ propertyZip?: string;
+ agencyName?: string;
+ bureauName?: string;
+ imageURL?: string;
+ instruction1?: string;
+ instruction2?: string;
+ instruction3?: string;
+}
+
+const numOr = (v: unknown, d = 0): number => {
+ const n = Number(String(v ?? "").replace(/[$,]/g, ""));
+ return Number.isFinite(n) ? n : d;
+};
+
+function mapRow(r: GsaRow): RawListing | null {
+ const sale = r.saleNo?.trim();
+ const lot = r.lotNo?.trim();
+ const title = r.itemName?.trim();
+ if (!sale || !title) return null;
+
+ const terms = [r.instruction1, r.instruction2, r.instruction3, r.reserve ? `Reserve: ${r.reserve}` : "", r.aucIncrement ? `Bid increment: ${r.aucIncrement}` : ""]
+ .filter(Boolean)
+ .join(" ");
+
+ return {
+ source: "GSA_AUCTIONS",
+ sourceAuctionId: [sale, lot].filter(Boolean).join("-"),
+ sourceUrl: r.itemDescURL || "https://gsaauctions.gov",
+ title,
+ description: [r.lotInfo, r.agencyName, r.bureauName].filter(Boolean).join(" · ") || undefined,
+ category: r.agencyName || undefined,
+ condition: "UNKNOWN",
+ quantity: 1,
+ locationCity: r.propertyCity,
+ locationState: r.propertyState,
+ locationZip: r.propertyZip,
+ currentBid: numOr(r.highBidAmount),
+ bidCount: numOr(r.biddersCount),
+ closingAt: r.aucEndDt || null,
+ imageUrls: r.imageURL ? [r.imageURL] : [],
+ auctionTerms: terms || undefined,
+ };
+}
+
+/** Fetch active GSA auction listings and normalize them to RawListing[]. */
+export async function fetchGsaAuctions(opts: { limit?: number; onlyOpen?: boolean } = {}): Promise<RawListing[]> {
+ const key = process.env.GSA_API_KEY || "DEMO_KEY";
+ const res = await fetch(GSA_URL, {
+ headers: { "X-API-KEY": key, Accept: "application/json" },
+ // fetch follows the 303 → S3 automatically.
+ });
+ if (!res.ok) {
+ throw new Error(`GSA Auctions API ${res.status}: ${(await res.text()).slice(0, 200)}`);
+ }
+ const data = (await res.json()) as { Results?: GsaRow[] };
+ const rows = (data.Results ?? []).map(mapRow).filter((x): x is RawListing => x !== null);
+
+ const filtered = opts.onlyOpen
+ ? rows.filter((r) => r.closingAt && new Date(r.closingAt).getTime() >= Date.now() - 86_400_000)
+ : rows;
+ return opts.limit ? filtered.slice(0, opts.limit) : filtered;
+}
diff --git a/src/importers/ingest.ts b/src/importers/ingest.ts
new file mode 100644
index 0000000..df822df
--- /dev/null
+++ b/src/importers/ingest.ts
@@ -0,0 +1,159 @@
+import { prisma } from "@/lib/db";
+import { runResearch } from "@/pipeline/research";
+import type { AuctionSource, Condition } from "@prisma/client";
+
+// Normalized import shape every importer (CSV, extension, URL scrape) produces.
+export interface RawListing {
+ source: string;
+ sourceAuctionId: string;
+ sourceUrl?: string;
+ title: string;
+ description?: string;
+ category?: string;
+ manufacturer?: string;
+ model?: string;
+ condition?: string;
+ quantity?: number;
+ weightLbs?: number;
+ dimensions?: string;
+ locationCity?: string;
+ locationState?: string;
+ locationZip?: string;
+ currentBid?: number;
+ bidCount?: number;
+ closingAt?: string | Date | null;
+ imageUrls?: string[];
+ auctionTerms?: string;
+}
+
+const SOURCE_MAP: Record<string, AuctionSource> = {
+ GOVDEALS: "GOVDEALS",
+ PUBLIC_SURPLUS: "PUBLIC_SURPLUS",
+ PUBLICSURPLUS: "PUBLIC_SURPLUS",
+ GSA_AUCTIONS: "GSA_AUCTIONS",
+ GSA: "GSA_AUCTIONS",
+ COUNTY: "COUNTY",
+ STATE_SURPLUS: "STATE_SURPLUS",
+ UNIVERSITY_SURPLUS: "UNIVERSITY_SURPLUS",
+ MUNICIBID: "MUNICIBID",
+ BID4ASSETS: "BID4ASSETS",
+ GOINDUSTRY: "GOINDUSTRY",
+ NETWORK_INTL: "NETWORK_INTL",
+ GRAYS_AU: "GRAYS_AU",
+ GOVPLANET: "GOVPLANET",
+ CSV: "CSV",
+ EXTENSION: "EXTENSION",
+};
+
+const CONDITION_MAP: Record<string, Condition> = {
+ NEW: "NEW",
+ LIKE_NEW: "LIKE_NEW",
+ LIKENEW: "LIKE_NEW",
+ USED_GOOD: "USED_GOOD",
+ GOOD: "USED_GOOD",
+ USED: "USED_GOOD",
+ USED_FAIR: "USED_FAIR",
+ FAIR: "USED_FAIR",
+ FOR_PARTS: "FOR_PARTS",
+ PARTS: "FOR_PARTS",
+ SALVAGE: "FOR_PARTS",
+ UNKNOWN: "UNKNOWN",
+};
+
+export function normalizeSource(s: string | undefined): AuctionSource {
+ return SOURCE_MAP[(s || "").toUpperCase().replace(/[\s-]/g, "_")] ?? "OTHER";
+}
+
+export function normalizeCondition(c: string | undefined): Condition {
+ return CONDITION_MAP[(c || "").toUpperCase().replace(/[\s-]/g, "_")] ?? "UNKNOWN";
+}
+
+export interface IngestResult {
+ listingId: string;
+ created: boolean;
+ research?: Awaited<ReturnType<typeof runResearch>>;
+}
+
+/**
+ * Ingest one normalized listing: upsert by (source, sourceAuctionId), then run
+ * the research pipeline (local AI identification + engines). Idempotent.
+ */
+export async function ingest(
+ raw: RawListing,
+ opts: { research?: boolean; useAI?: boolean } = {},
+): Promise<IngestResult> {
+ if (!raw.title || !raw.sourceAuctionId) {
+ throw new Error("Import requires at least title and sourceAuctionId");
+ }
+ const source = normalizeSource(raw.source);
+ const closingAt = raw.closingAt ? new Date(raw.closingAt) : null;
+
+ const existing = await prisma.listing.findUnique({
+ where: { source_sourceAuctionId: { source, sourceAuctionId: raw.sourceAuctionId } },
+ });
+
+ const data = {
+ source,
+ sourceAuctionId: raw.sourceAuctionId,
+ sourceUrl: raw.sourceUrl ?? null,
+ title: raw.title,
+ description: raw.description ?? null,
+ category: raw.category ?? null,
+ manufacturer: raw.manufacturer ?? null,
+ model: raw.model ?? null,
+ condition: normalizeCondition(raw.condition),
+ quantity: raw.quantity && raw.quantity > 0 ? Math.floor(raw.quantity) : 1,
+ weightLbs: raw.weightLbs ?? null,
+ dimensions: raw.dimensions ?? null,
+ locationCity: raw.locationCity ?? null,
+ locationState: raw.locationState ?? null,
+ locationZip: raw.locationZip ?? null,
+ currentBid: raw.currentBid ?? 0,
+ bidCount: raw.bidCount ?? 0,
+ closingAt: closingAt && !isNaN(closingAt.getTime()) ? closingAt : null,
+ imageUrls: raw.imageUrls ?? [],
+ auctionTerms: raw.auctionTerms ?? null,
+ // Seeing the item in an import = it's live on the source right now.
+ // Refresh lastSeenAt and reactivate (an item that reappears is live again).
+ lastSeenAt: new Date(),
+ listingStatus: "ACTIVE" as const,
+ endedAt: null,
+ };
+
+ const listing = existing
+ ? await prisma.listing.update({ where: { id: existing.id }, data })
+ : await prisma.listing.create({ data: { ...data, researchStatus: "PENDING" } });
+
+ await prisma.listingEvent.create({
+ data: {
+ listingId: listing.id,
+ type: existing ? "MANUAL_EDIT" : "IMPORTED",
+ message: existing ? `Re-imported from ${source}` : `Imported from ${source}`,
+ },
+ });
+
+ let research;
+ if (opts.research !== false) {
+ // Live single imports use the local AI identifier; bulk imports pass
+ // useAI:false for speed (the worker AI-enriches later). Failures degrade
+ // to the heuristic path inside runResearch either way.
+ const useAI = opts.useAI !== false;
+ research = await runResearch(listing.id, { useAI, writeIdentity: useAI });
+ }
+
+ return { listingId: listing.id, created: !existing, research };
+}
+
+/** Bulk ingest with per-row error capture. */
+export async function ingestMany(rows: RawListing[], opts: { research?: boolean; useAI?: boolean } = {}) {
+ const results: { ok: boolean; listingId?: string; created?: boolean; error?: string; title: string }[] = [];
+ for (const row of rows) {
+ try {
+ const r = await ingest(row, opts);
+ results.push({ ok: true, listingId: r.listingId, created: r.created, title: row.title });
+ } catch (e) {
+ results.push({ ok: false, error: (e as Error).message, title: row.title || "(untitled)" });
+ }
+ }
+ return results;
+}
diff --git a/src/importers/municibid-free.ts b/src/importers/municibid-free.ts
new file mode 100644
index 0000000..1e5dcc8
--- /dev/null
+++ b/src/importers/municibid-free.ts
@@ -0,0 +1,246 @@
+import type { RawListing } from "./ingest";
+
+// FREE Municibid importer. $0, no browser at runtime.
+//
+// Municibid is a classic server-side-rendered ASP.NET site (jQuery + SignalR
+// for live bid pushes) — there is NO JSON search/listings API to replay. The
+// listing cards arrive inside the initial /browse HTML document, and plain
+// curl gets HTTP 200 (no Cloudflare wall). So the crack is straight HTML
+// parsing of the `.browse-item` cards, paginated via `?page=N`.
+//
+// Verified 2026-07-10 via openclaw real-Chrome capture:
+// - performance.getEntriesByType('resource') showed ZERO listings API —
+// only analytics (Google/Clarity/Customer.io), FontAwesome, wisepops, and
+// SignalR (/signalr/* on `listinghub` = live bid-price push, not data).
+// - The "gist.build" lead from an earlier pass was a red herring: it's the
+// Customer.io "Gist" support-chat widget, unrelated to auctions.
+//
+// Card shape (per `div.browse-item[data-listingid]`):
+// id -> data-listingid
+// title -> desktop <h2 class="text-card"><a>FULL TITLE</a>
+// url -> /Listing/Details/{id}/{slug}
+// bid -> <span class="awe-rt-CurrentPrice ...">$<span class="NumberPart">235.00</span>
+// bids -> <span class="awe-rt-AcceptedListingActionCount" ...>8</span>
+// close -> <span data-epoch="ending" data-action-time="MM/DD/YYYY HH:MM:SS">
+// image -> storagemunicibidpro.blob.core.windows.net/assets/media/{uuid}_thumbcrop.jpg
+// loc -> <p class="card-subtitle">City , ST | Seller Agency</p>
+
+const BASE = "https://municibid.com";
+const UA =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+
+const STATE_ABBR = new Set([
+ "AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA","KS",
+ "KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ","NM","NY",
+ "NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT","VT","VA","WA","WV",
+ "WI","WY","DC","PR",
+]);
+
+function decode(s: string | undefined): string {
+ if (!s) return "";
+ return s
+ .replace(/&/g, "&")
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/ /g, " ")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function first(re: RegExp, s: string): string | undefined {
+ const m = re.exec(s);
+ return m ? m[1] : undefined;
+}
+
+// Parse "MM/DD/YYYY HH:MM:SS" (US Eastern, Municibid's server zone) to ISO.
+// We treat it as local wall-clock; exactness to the minute isn't required by
+// the research pipeline, and a naive Date parse of this format is stable.
+function parseCloseTime(raw: string | undefined): string | null {
+ if (!raw) return null;
+ const m = /(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})/.exec(raw);
+ if (!m) return null;
+ const [, mo, d, y, h, mi, s] = m;
+ const dt = new Date(
+ Number(y),
+ Number(mo) - 1,
+ Number(d),
+ Number(h),
+ Number(mi),
+ Number(s),
+ );
+ return isNaN(dt.getTime()) ? null : dt.toISOString();
+}
+
+function parseCard(block: string): RawListing | null {
+ const id = first(/data-listingid="(\d+)"/, block);
+ if (!id) return null;
+
+ // Full (untruncated) title from the desktop `.text-card` <h2><a>…</a>.
+ // Fall back to the slug in the detail URL, or the truncated card-title.
+ const detailRe = new RegExp(
+ `/Listing/Details/${id}/([A-Za-z0-9%\\-_.]+)`,
+ );
+ const slug = first(detailRe, block);
+ let title = first(
+ /class="text-card"[^>]*>\s*<a[^>]*>([\s\S]*?)<\/a>/,
+ block,
+ );
+ title = decode(title);
+ if (!title && slug) {
+ title = decode(decodeURIComponent(slug).replace(/[-_]+/g, " "));
+ }
+ if (!title) {
+ title = decode(
+ first(/class="card-title[^"]*"[^>]*>\s*<a[^>]*>([\s\S]*?)<\/a>/, block),
+ ).replace(/\.\.\.$/, "");
+ }
+ if (!title) return null;
+
+ const url = slug
+ ? `${BASE}/Listing/Details/${id}/${slug}`
+ : `${BASE}/Listing/Details/${id}`;
+
+ // Current bid: $<span class="NumberPart">235.00</span>
+ const priceStr = first(
+ /awe-rt-CurrentPrice[^>]*>\s*\$?\s*<span class="NumberPart">([\d,]+(?:\.\d+)?)<\/span>/,
+ block,
+ );
+ const currentBid = priceStr ? Number(priceStr.replace(/,/g, "")) : 0;
+
+ // Bid count.
+ const bidStr = first(
+ /awe-rt-AcceptedListingActionCount[^>]*>\s*(\d+)\s*</,
+ block,
+ );
+ const bidCount = bidStr ? Number(bidStr) : 0;
+
+ // Absolute close time.
+ const closingAt = parseCloseTime(
+ first(/data-epoch="ending"[^>]*data-action-time="([^"]+)"/, block) ||
+ first(/data-action-time="([^"]+)"[^>]*data-epoch="ending"/, block),
+ );
+
+ // Image (thumbcrop → strip to a larger asset when possible).
+ let img = first(
+ /(https:\/\/storagemunicibidpro\.blob\.core\.windows\.net\/assets\/media\/[A-Za-z0-9-]+_thumbcrop\.jpg)/,
+ block,
+ );
+ const imageUrls = img ? [img] : [];
+
+ // Location + seller from card-subtitle: "City , ST | Seller Agency".
+ const subtitle = decode(
+ first(/class="card-subtitle[^"]*"[^>]*>([\s\S]*?)<\/p>/, block),
+ );
+ let locationCity: string | undefined;
+ let locationState: string | undefined;
+ let seller: string | undefined;
+ if (subtitle) {
+ const [locPart, ...rest] = subtitle.split("|");
+ seller = rest.join("|").trim() || undefined;
+ const lm = /^(.*?)[,\s]+([A-Z]{2})\s*$/.exec(locPart.trim());
+ if (lm && STATE_ABBR.has(lm[2])) {
+ locationCity = lm[1].replace(/,\s*$/, "").trim() || undefined;
+ locationState = lm[2];
+ } else {
+ locationCity = locPart.trim() || undefined;
+ }
+ }
+
+ const descParts = [
+ seller ? `Seller: ${seller}` : "",
+ locationCity || locationState
+ ? `Location: ${[locationCity, locationState].filter(Boolean).join(", ")}`
+ : "",
+ ].filter(Boolean);
+
+ return {
+ source: "MUNICIBID",
+ sourceAuctionId: id,
+ sourceUrl: url,
+ title,
+ description: descParts.join(" · ") || undefined,
+ // NB: `seller` is the government agency, NOT the product maker — keep it in
+ // description only. Leaving `manufacturer` unset lets the research pipeline
+ // extract the real brand from the title.
+ locationCity,
+ locationState,
+ currentBid,
+ bidCount,
+ closingAt,
+ imageUrls,
+ };
+}
+
+async function fetchPage(page: number): Promise<string> {
+ const url = page <= 1 ? `${BASE}/browse` : `${BASE}/browse?page=${page}`;
+ const res = await fetch(url, {
+ headers: {
+ "User-Agent": UA,
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "en-US,en;q=0.9",
+ },
+ });
+ if (!res.ok) throw new Error(`Municibid /browse page ${page} -> HTTP ${res.status}`);
+ return res.text();
+}
+
+function parsePage(html: string): RawListing[] {
+ // Split into per-card blocks on the data-listingid boundary. Each card's
+ // markup (mobile + desktop) lives between one data-listingid and the next.
+ const parts = html.split(/(?=<div class="row browse-item)/);
+ const out: RawListing[] = [];
+ for (const part of parts) {
+ if (!/data-listingid="\d+"/.test(part)) continue;
+ const card = parseCard(part);
+ if (card) out.push(card);
+ }
+ return out;
+}
+
+export interface FetchMunicibidOpts {
+ limit?: number;
+ maxPages?: number;
+}
+
+/**
+ * Fetch up to `limit` active Municibid listings by paginating /browse?page=N.
+ * Dedupes by listing id. $0 — plain HTTPS, no browser, no API key.
+ */
+export async function fetchMunicibidFree(
+ opts: FetchMunicibidOpts = {},
+): Promise<RawListing[]> {
+ const limit = opts.limit ?? 100;
+ const maxPages = opts.maxPages ?? 40;
+ const seen = new Set<string>();
+ const rows: RawListing[] = [];
+
+ for (let page = 1; page <= maxPages && rows.length < limit; page++) {
+ let html: string;
+ try {
+ html = await fetchPage(page);
+ } catch (e) {
+ console.warn(`[municibid-free] page ${page} fetch failed: ${(e as Error).message}`);
+ break;
+ }
+ const cards = parsePage(html);
+ if (cards.length === 0) break; // ran past the last page
+ let added = 0;
+ for (const c of cards) {
+ if (seen.has(c.sourceAuctionId)) continue;
+ seen.add(c.sourceAuctionId);
+ rows.push(c);
+ added++;
+ if (rows.length >= limit) break;
+ }
+ // If a full page yielded no NEW ids, we've looped back to the start.
+ if (added === 0) break;
+ // Be polite: small jitter between page fetches.
+ await new Promise((r) => setTimeout(r, 350 + Math.floor(page % 3) * 120));
+ }
+
+ return rows.slice(0, limit);
+}
diff --git a/src/importers/publicsurplus-free.ts b/src/importers/publicsurplus-free.ts
new file mode 100644
index 0000000..8c03d1a
--- /dev/null
+++ b/src/importers/publicsurplus-free.ts
@@ -0,0 +1,211 @@
+import type { RawListing } from "./ingest";
+
+// FREE Public Surplus importer. $0, no browser at runtime.
+//
+// Public Surplus is server-rendered (Java/Spring `/sms/…` app). The category
+// listing pages return fully-rendered auction cards to plain curl (HTTP 200,
+// no bot wall), so the crack is HTML parsing of the `.auction-item` cards.
+//
+// Verified 2026-07-10:
+// list -> GET /sms/browse/cataucs?catid={C}&page={P} (25 cards/page, 0-idx)
+// item -> /sms/auction/view?auc={id}
+// card (per `div.auction-item[id="{id}catGrid"]`):
+// id -> id="{id}catGrid"
+// title -> <a href="/sms/auction/view?auc={id}" title="#{id} - {TITLE}">
+// state -> <span class="auction-item-state"> CA </span>
+// price -> <b id="val_{id}catGrid"> $35.00 </b>
+// time -> <span id="timeLeftValue{id}catGrid" ...> 4 mins </span> (RELATIVE)
+// image -> https://d37qv0n5b4mbzm.cloudfront.net/.../thumb-b/{id}/{doc}
+//
+// Close time is relative ("4 mins" / "2 days" / "1 hour"), not absolute, so we
+// compute closingAt = now + parsed delta. Categories enumerated from the home
+// page (catid 1..29). No API key, no browser.
+
+const BASE = "https://www.publicsurplus.com";
+const UA =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+
+// Category ids present on the browse home (2026-07-10). Enumerated rather than
+// hardcoded 1..N because 7 is absent; kept as a static list for stability.
+const CATEGORY_IDS = [
+ 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
+ 23, 24, 25, 26, 27, 28, 29,
+];
+
+function decode(s: string | undefined): string {
+ if (!s) return "";
+ return s
+ .replace(/&/g, "&")
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/ /g, " ")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function first(re: RegExp, s: string): string | undefined {
+ const m = re.exec(s);
+ return m ? m[1] : undefined;
+}
+
+// Parse relative "Time Left" like "4 mins", "2 days", "1 hour", "3 hrs",
+// "5 days, 2 hours" -> ISO close time (now + delta). Returns null if unparseable
+// or already ended.
+function parseRelativeClose(raw: string | undefined): string | null {
+ if (!raw) return null;
+ const txt = raw.toLowerCase();
+ if (/ended|closed|over/.test(txt)) return null;
+ const units: [RegExp, number][] = [
+ [/(\d+)\s*day/, 86400_000],
+ [/(\d+)\s*h(?:ou)?r/, 3600_000],
+ [/(\d+)\s*min/, 60_000],
+ [/(\d+)\s*sec/, 1000],
+ ];
+ let deltaMs = 0;
+ let matched = false;
+ for (const [re, ms] of units) {
+ const m = re.exec(txt);
+ if (m) {
+ deltaMs += Number(m[1]) * ms;
+ matched = true;
+ }
+ }
+ if (!matched) return null;
+ return new Date(Date.now() + deltaMs).toISOString();
+}
+
+function parseCard(block: string, id: string): RawListing | null {
+ // Title: prefer the <a title="#id - TITLE"> attribute (full), strip the
+ // leading "#id - " prefix.
+ let title = decode(
+ first(
+ new RegExp(`/sms/auction/view\\?auc=${id}"[^>]*title="([^"]+)"`),
+ block,
+ ),
+ );
+ title = title.replace(new RegExp(`^#?${id}\\s*[-–]\\s*`), "").trim();
+ if (!title) {
+ // fallback: visible anchor text
+ title = decode(
+ first(
+ new RegExp(`/sms/auction/view\\?auc=${id}"[^>]*>([\\s\\S]*?)</a>`),
+ block,
+ ),
+ ).replace(new RegExp(`^#?${id}\\s*[-–]\\s*`), "");
+ }
+ if (!title || title === "...") return null;
+
+ const priceStr = first(
+ new RegExp(`id="val_${id}catGrid"[^>]*>\\s*\\$?([\\d,]+(?:\\.\\d+)?)`),
+ block,
+ );
+ const currentBid = priceStr ? Number(priceStr.replace(/,/g, "")) : 0;
+
+ const timeRaw = decode(
+ first(
+ new RegExp(`id="timeLeftValue${id}catGrid"[^>]*>([\\s\\S]*?)</span>`),
+ block,
+ ),
+ );
+ const closingAt = parseRelativeClose(timeRaw);
+
+ const state = decode(
+ first(/class="auction-item-state"[^>]*>([\s\S]*?)<\/span>/, block),
+ );
+
+ const img = first(
+ /(https:\/\/[a-z0-9]+\.cloudfront\.net\/sms\/docviewer\/[^\s"']+)/,
+ block,
+ );
+
+ return {
+ source: "PUBLIC_SURPLUS",
+ sourceAuctionId: id,
+ sourceUrl: `${BASE}/sms/auction/view?auc=${id}`,
+ title,
+ locationState: state && /^[A-Z]{2}$/.test(state) ? state : undefined,
+ currentBid,
+ bidCount: 0, // not shown on the list card; enriched later if needed
+ closingAt,
+ imageUrls: img ? [img] : [],
+ };
+}
+
+function parsePage(html: string): RawListing[] {
+ const out: RawListing[] = [];
+ // Each card is `<div class="auction-item" id="{id}catGrid">`. Split on that
+ // boundary and parse each block against its own id.
+ const re = /id="(\d+)catGrid"/g;
+ const ids: { id: string; idx: number }[] = [];
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(html))) ids.push({ id: m[1], idx: m.index });
+ for (let i = 0; i < ids.length; i++) {
+ const start = ids[i].idx;
+ const end = i + 1 < ids.length ? ids[i + 1].idx : Math.min(html.length, start + 4000);
+ const block = html.slice(start, end);
+ const card = parseCard(block, ids[i].id);
+ if (card) out.push(card);
+ }
+ return out;
+}
+
+async function fetchList(catid: number, page: number): Promise<string> {
+ const url = `${BASE}/sms/browse/cataucs?catid=${catid}&page=${page}`;
+ const res = await fetch(url, {
+ headers: {
+ "User-Agent": UA,
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "en-US,en;q=0.9",
+ },
+ });
+ if (!res.ok) throw new Error(`PublicSurplus cataucs c=${catid} p=${page} -> HTTP ${res.status}`);
+ return res.text();
+}
+
+export interface FetchPublicSurplusOpts {
+ limit?: number;
+ pagesPerCategory?: number;
+}
+
+/**
+ * Fetch up to `limit` active Public Surplus listings by walking categories and
+ * paginating each. Dedupes by auction id. $0 — plain HTTPS, no browser/API key.
+ */
+export async function fetchPublicSurplusFree(
+ opts: FetchPublicSurplusOpts = {},
+): Promise<RawListing[]> {
+ const limit = opts.limit ?? 100;
+ const pagesPerCategory = opts.pagesPerCategory ?? 4;
+ const seen = new Set<string>();
+ const rows: RawListing[] = [];
+
+ outer: for (const catid of CATEGORY_IDS) {
+ for (let page = 0; page < pagesPerCategory; page++) {
+ let html: string;
+ try {
+ html = await fetchList(catid, page);
+ } catch (e) {
+ console.warn(`[publicsurplus-free] ${(e as Error).message}`);
+ break; // next category
+ }
+ const cards = parsePage(html);
+ if (cards.length === 0) break; // past the last page of this category
+ let added = 0;
+ for (const c of cards) {
+ if (seen.has(c.sourceAuctionId)) continue;
+ seen.add(c.sourceAuctionId);
+ rows.push(c);
+ added++;
+ if (rows.length >= limit) break outer;
+ }
+ if (added === 0) break; // looped back — next category
+ await new Promise((r) => setTimeout(r, 300 + (page % 3) * 120));
+ }
+ }
+
+ return rows.slice(0, limit);
+}
diff --git a/src/importers/scrape.ts b/src/importers/scrape.ts
new file mode 100644
index 0000000..cf79eca
--- /dev/null
+++ b/src/importers/scrape.ts
@@ -0,0 +1,80 @@
+import type { RawListing } from "./ingest";
+
+// Best-effort single-URL importer. Uses Playwright (a devDependency) to render
+// the page, then extracts fields with host-specific selectors and generic
+// fallbacks (og: tags, $-regex). Live auction-site DOMs change often, so this is
+// intentionally defensive: whatever it can't find is left undefined and the
+// research pipeline's AI identifier fills gaps from the title/description.
+
+function sourceFromHost(host: string): string {
+ if (host.includes("govdeals")) return "GOVDEALS";
+ if (host.includes("publicsurplus")) return "PUBLIC_SURPLUS";
+ if (host.includes("gsaauctions")) return "GSA_AUCTIONS";
+ if (host.includes("municibid")) return "MUNICIBID";
+ if (host.includes("bid4assets")) return "BID4ASSETS";
+ return "OTHER";
+}
+
+function auctionIdFromUrl(u: URL): string {
+ const q =
+ u.searchParams.get("itemid") ||
+ u.searchParams.get("auc") ||
+ u.searchParams.get("id") ||
+ u.searchParams.get("ac");
+ if (q) return q;
+ const segs = u.pathname.split("/").filter(Boolean);
+ return segs[segs.length - 1] || u.pathname;
+}
+
+export async function scrapeUrl(url: string): Promise<RawListing> {
+ const u = new URL(url);
+ const source = sourceFromHost(u.hostname);
+
+ // Dynamic import so the main app bundle never hard-depends on Playwright.
+ let chromium: typeof import("playwright").chromium;
+ try {
+ ({ chromium } = await import("playwright"));
+ } catch {
+ throw new Error(
+ "Playwright is not installed. Run `npx playwright install chromium` to enable URL import.",
+ );
+ }
+
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage({ userAgent: "Mozilla/5.0 GovArbitrageBot/0.1" });
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
+
+ const extracted = await page.evaluate(() => {
+ const meta = (p: string) =>
+ (document.querySelector(`meta[property="${p}"]`) as HTMLMetaElement | null)?.content ||
+ (document.querySelector(`meta[name="${p}"]`) as HTMLMetaElement | null)?.content ||
+ undefined;
+ const text = document.body?.innerText || "";
+ const priceMatch = text.match(/\$\s?([\d,]+(?:\.\d{2})?)/);
+ const images = Array.from(document.querySelectorAll("img"))
+ .map((i) => (i as HTMLImageElement).src)
+ .filter((s) => s && s.startsWith("http"))
+ .slice(0, 6);
+ const ogImage = meta("og:image");
+ return {
+ title: meta("og:title") || document.querySelector("h1")?.textContent?.trim() || document.title,
+ description: meta("og:description") || undefined,
+ currentBid: priceMatch ? Number(priceMatch[1].replace(/,/g, "")) : undefined,
+ imageUrls: ogImage ? [ogImage, ...images] : images,
+ };
+ });
+
+ return {
+ source,
+ sourceAuctionId: auctionIdFromUrl(u),
+ sourceUrl: url,
+ title: extracted.title || `${source} lot ${auctionIdFromUrl(u)}`,
+ description: extracted.description,
+ currentBid: extracted.currentBid,
+ imageUrls: extracted.imageUrls,
+ };
+ } finally {
+ await browser.close();
+ }
+}
diff --git a/src/lib/ai.ts b/src/lib/ai.ts
new file mode 100644
index 0000000..725384a
--- /dev/null
+++ b/src/lib/ai.ts
@@ -0,0 +1,122 @@
+// Local-first AI layer. Product identification runs on Ollama (this machine,
+// $0/query). Anthropic is an opt-in fallback only, enabled by setting
+// AI_PROVIDER=anthropic and ANTHROPIC_API_KEY. If no model is reachable, callers
+// fall back to the deterministic heuristic in engines/demand.ts.
+
+const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || "http://localhost:11434";
+const TEXT_MODEL = process.env.OLLAMA_TEXT_MODEL || "qwen3:14b";
+const VISION_MODEL = process.env.OLLAMA_VISION_MODEL || "qwen2.5vl:7b";
+
+export interface AiIdentification {
+ manufacturer?: string;
+ model?: string;
+ category?: string;
+ estimatedNewRetail?: number;
+ demandScore?: number; // 0..100
+ confidence?: number; // 0..1
+ reasoning?: string;
+}
+
+const SYSTEM = `You are a resale-arbitrage product analyst. Given a government-surplus
+auction listing, identify the product and estimate its market. Respond ONLY with
+compact JSON matching:
+{"manufacturer":string,"model":string,"category":string,"estimatedNewRetail":number,"demandScore":number,"confidence":number,"reasoning":string}
+demandScore is 0-100 (secondary-market liquidity). confidence is 0-1. Use USD.`;
+
+async function ollamaJson(prompt: string, model = TEXT_MODEL, images?: string[]): Promise<AiIdentification | null> {
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 45_000);
+ const res = await fetch(`${OLLAMA_BASE}/api/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ signal: controller.signal,
+ body: JSON.stringify({
+ model,
+ prompt: `${SYSTEM}\n\nLISTING:\n${prompt}`,
+ stream: false,
+ format: "json",
+ options: { temperature: 0.1 },
+ ...(images && images.length ? { images } : {}),
+ }),
+ });
+ clearTimeout(timeout);
+ if (!res.ok) return null;
+ const data = (await res.json()) as { response?: string };
+ if (!data.response) return null;
+ return JSON.parse(data.response) as AiIdentification;
+ } catch {
+ return null;
+ }
+}
+
+/** Identify a product from listing text (and optionally base64 images). */
+export async function identifyProduct(args: {
+ title: string;
+ description?: string | null;
+ category?: string | null;
+ imagesBase64?: string[];
+}): Promise<{ result: AiIdentification | null; model: string }> {
+ const provider = process.env.AI_PROVIDER || "ollama";
+ const text = [
+ `Title: ${args.title}`,
+ args.category ? `Category: ${args.category}` : "",
+ args.description ? `Description: ${args.description}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n");
+
+ // Vision path when images are supplied and a vision model is configured.
+ if (args.imagesBase64 && args.imagesBase64.length) {
+ const v = await ollamaJson(text, VISION_MODEL, args.imagesBase64);
+ if (v) return { result: v, model: `ollama:${VISION_MODEL}` };
+ }
+
+ const t = await ollamaJson(text, TEXT_MODEL);
+ if (t) return { result: t, model: `ollama:${TEXT_MODEL}` };
+
+ // Opt-in cloud fallback.
+ if (provider === "anthropic" && process.env.ANTHROPIC_API_KEY) {
+ const a = await anthropicJson(text);
+ if (a) return { result: a, model: "anthropic:claude" };
+ }
+
+ return { result: null, model: "heuristic" };
+}
+
+async function anthropicJson(text: string): Promise<AiIdentification | null> {
+ try {
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": process.env.ANTHROPIC_API_KEY as string,
+ "anthropic-version": "2023-06-01",
+ },
+ body: JSON.stringify({
+ model: "claude-haiku-4-5-20251001",
+ max_tokens: 512,
+ system: SYSTEM,
+ messages: [{ role: "user", content: `LISTING:\n${text}` }],
+ }),
+ });
+ if (!res.ok) return null;
+ const data = (await res.json()) as { content?: { text?: string }[] };
+ const raw = data.content?.[0]?.text;
+ if (!raw) return null;
+ const match = raw.match(/\{[\s\S]*\}/);
+ return match ? (JSON.parse(match[0]) as AiIdentification) : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Is a local model reachable? Used by health checks + the worker banner. */
+export async function ollamaReachable(): Promise<boolean> {
+ try {
+ const res = await fetch(`${OLLAMA_BASE}/api/tags`, { signal: AbortSignal.timeout(3000) });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts
new file mode 100644
index 0000000..cf22604
--- /dev/null
+++ b/src/lib/auth.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it, beforeAll } from "vitest";
+import { hashPassword, verifyPassword } from "./password";
+import { createSession, verifySession } from "./session";
+
+beforeAll(() => {
+ process.env.AUTH_SECRET = "test-secret-please-change";
+});
+
+describe("password hashing (scrypt)", () => {
+ it("verifies a correct password and rejects a wrong one", () => {
+ const hash = hashPassword("s3cret-pw");
+ expect(hash.startsWith("scrypt$")).toBe(true);
+ expect(verifyPassword("s3cret-pw", hash)).toBe(true);
+ expect(verifyPassword("wrong", hash)).toBe(false);
+ });
+
+ it("salts so identical passwords hash differently", () => {
+ expect(hashPassword("same")).not.toBe(hashPassword("same"));
+ });
+
+ it("rejects malformed stored hashes", () => {
+ expect(verifyPassword("x", "$demo$changeme")).toBe(false);
+ expect(verifyPassword("x", "garbage")).toBe(false);
+ });
+});
+
+describe("session JWT", () => {
+ it("round-trips a session payload", async () => {
+ const token = await createSession({ sub: "u1", email: "a@b.com", role: "ADMIN" });
+ const payload = await verifySession(token);
+ expect(payload?.sub).toBe("u1");
+ expect(payload?.email).toBe("a@b.com");
+ expect(payload?.role).toBe("ADMIN");
+ });
+
+ it("rejects a tampered/garbage token", async () => {
+ expect(await verifySession("not.a.jwt")).toBeNull();
+ const token = await createSession({ sub: "u1", email: "a@b.com", role: "ADMIN" });
+ expect(await verifySession(token + "x")).toBeNull();
+ });
+});
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..c9b639e
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server";
+import { SESSION_COOKIE, verifySession } from "@/lib/session";
+
+export type Role = "ADMIN" | "ANALYST" | "VIEWER";
+const RANK: Record<Role, number> = { VIEWER: 0, ANALYST: 1, ADMIN: 2 };
+
+/** Resolve the caller's role from the session cookie, or null if unauthenticated. */
+async function sessionRole(req: NextRequest): Promise<Role | null> {
+ const token = req.cookies.get(SESSION_COOKIE)?.value;
+ const s = token ? await verifySession(token) : null;
+ if (!s) return null;
+ return ((s.role as Role) ?? "VIEWER");
+}
+
+function deny(role: Role | null): NextResponse {
+ // Distinguish unauthenticated (401) from authenticated-but-insufficient (403).
+ const status = role ? 403 : 401;
+ return NextResponse.json({ error: status === 403 ? "Forbidden" : "Unauthorized" }, { status });
+}
+
+/**
+ * Write-guard for import + operational endpoints (defense-in-depth behind the
+ * middleware). Allows the request when EITHER:
+ * • a logged-in user whose role meets `minRole` (default ANALYST — a VIEWER
+ * session, including one minted from the fleet SSO cookie, CANNOT write), OR
+ * • the machine `x-import-token` header matches IMPORT_TOKEN (extension / CSV).
+ * Returns a 401/403 NextResponse when denied, or null when allowed.
+ */
+export async function requireWrite(req: NextRequest, minRole: Role = "ANALYST"): Promise<NextResponse | null> {
+ const role = await sessionRole(req);
+ if (role && RANK[role] >= RANK[minRole]) return null;
+
+ const importToken = process.env.IMPORT_TOKEN;
+ if (importToken && req.headers.get("x-import-token") === importToken) return null;
+
+ return deny(role);
+}
+
+/**
+ * Admin-only guard for tier-0 surfaces (stored provider credentials/secrets).
+ * NO machine-token bypass and NO fleet-SSO VIEWER access — a full ADMIN session
+ * is required, which on this app means the local username/password login.
+ */
+export async function requireAdmin(req: NextRequest): Promise<NextResponse | null> {
+ const role = await sessionRole(req);
+ if (role === "ADMIN") return null;
+ return deny(role);
+}
diff --git a/src/lib/billing.ts b/src/lib/billing.ts
new file mode 100644
index 0000000..063dff4
--- /dev/null
+++ b/src/lib/billing.ts
@@ -0,0 +1,86 @@
+// Stripe billing for the paid-alerts SaaS — TEST/MOCK ONLY.
+// HARD RAIL: a live secret key (sk_live_) is REFUSED — going live is Steve
+// flipping the switch himself, never us. With no key we run in MOCK mode: no
+// real Stripe call, but the upgrade flow still completes so it's demoable.
+import Stripe from "stripe";
+import type { Tier } from "@prisma/client";
+import { TIERS } from "@/lib/tiers";
+
+const KEY = process.env.STRIPE_TEST_SECRET_KEY || "";
+export const LIVE_KEY_REFUSED = KEY.startsWith("sk_live_");
+export const STRIPE_MODE: "test" | "mock" =
+ KEY.startsWith("sk_test_") && !LIVE_KEY_REFUSED ? "test" : "mock";
+
+let _stripe: Stripe | null = null;
+function stripe(): Stripe | null {
+ if (STRIPE_MODE !== "test") return null;
+ if (!_stripe) _stripe = new Stripe(KEY);
+ return _stripe;
+}
+
+export interface CheckoutResult {
+ ok: boolean;
+ mode: "test" | "mock";
+ url: string;
+ error?: string;
+}
+
+/**
+ * Create a (TEST) subscription Checkout session for a tier. In mock mode returns
+ * a local success URL that simulates the upgrade so the flow is demoable.
+ */
+export async function createCheckout(opts: {
+ userId: string;
+ email: string;
+ tier: Tier;
+ baseUrl: string;
+}): Promise<CheckoutResult> {
+ const def = TIERS[opts.tier];
+ if (!def || def.priceUsd <= 0) {
+ return { ok: false, mode: STRIPE_MODE, url: "", error: "not a paid tier" };
+ }
+ if (LIVE_KEY_REFUSED) {
+ return { ok: false, mode: "mock", url: "", error: "live key refused — TEST only" };
+ }
+
+ // MOCK: no Stripe call; simulate the successful upgrade locally.
+ if (STRIPE_MODE === "mock") {
+ const url = `${opts.baseUrl}/billing/success?tier=${opts.tier}&mock=1&session=mock_${opts.userId.slice(0, 8)}`;
+ return { ok: true, mode: "mock", url };
+ }
+
+ // TEST: real Stripe test-mode subscription checkout with inline recurring price.
+ const s = stripe()!;
+ const session = await s.checkout.sessions.create({
+ mode: "subscription",
+ customer_email: opts.email,
+ client_reference_id: opts.userId,
+ metadata: { userId: opts.userId, tier: opts.tier },
+ line_items: [
+ {
+ quantity: 1,
+ price_data: {
+ currency: "usd",
+ recurring: { interval: "month" },
+ unit_amount: Math.round(def.priceUsd * 100),
+ product_data: { name: `GovArbitrage ${def.label} — hot-deal alerts` },
+ },
+ },
+ ],
+ success_url: `${opts.baseUrl}/billing/success?tier=${opts.tier}&session={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${opts.baseUrl}/pricing?canceled=1`,
+ });
+ return { ok: true, mode: "test", url: session.url || "" };
+}
+
+/** Verify a Stripe webhook (TEST). Returns the event or null if unverifiable. */
+export function verifyWebhook(rawBody: string, sig: string | null): Stripe.Event | null {
+ const s = stripe();
+ const secret = process.env.STRIPE_WEBHOOK_SECRET || "";
+ if (!s || !sig || !secret) return null;
+ try {
+ return s.webhooks.constructEvent(rawBody, sig, secret);
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/crypto.test.ts b/src/lib/crypto.test.ts
new file mode 100644
index 0000000..e32e8a7
--- /dev/null
+++ b/src/lib/crypto.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, beforeAll } from "vitest";
+import { encryptSecret, decryptSecret } from "./crypto";
+
+beforeAll(() => {
+ process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+});
+
+describe("crypto (AES-256-GCM secret storage)", () => {
+ it("round-trips a secret", () => {
+ const enc = encryptSecret("sk-super-secret-token");
+ expect(enc.ciphertext).not.toContain("super-secret");
+ expect(decryptSecret(enc)).toBe("sk-super-secret-token");
+ });
+
+ it("produces a fresh IV each time (non-deterministic ciphertext)", () => {
+ const a = encryptSecret("same");
+ const b = encryptSecret("same");
+ expect(a.iv).not.toBe(b.iv);
+ expect(a.ciphertext).not.toBe(b.ciphertext);
+ });
+
+ it("rejects a tampered auth tag", () => {
+ const enc = encryptSecret("payload");
+ expect(() => decryptSecret({ ...enc, authTag: Buffer.from("00".repeat(16), "hex").toString("base64") })).toThrow();
+ });
+});
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
new file mode 100644
index 0000000..571ad21
--- /dev/null
+++ b/src/lib/crypto.ts
@@ -0,0 +1,40 @@
+import crypto from "node:crypto";
+
+// AES-256-GCM encryption for secrets at rest (e.g. provider API keys stored in
+// ApiCredential). ENCRYPTION_KEY must be 32 bytes as 64 hex chars.
+
+export interface Encrypted {
+ ciphertext: string; // base64
+ iv: string; // base64
+ authTag: string; // base64
+}
+
+function key(): Buffer {
+ const hex = process.env.ENCRYPTION_KEY || "";
+ const buf = Buffer.from(hex, "hex");
+ if (buf.length !== 32) {
+ throw new Error("ENCRYPTION_KEY must be 32 bytes (64 hex chars) for AES-256-GCM");
+ }
+ return buf;
+}
+
+export function encryptSecret(plaintext: string): Encrypted {
+ const iv = crypto.randomBytes(12);
+ const cipher = crypto.createCipheriv("aes-256-gcm", key(), iv);
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
+ return {
+ ciphertext: ct.toString("base64"),
+ iv: iv.toString("base64"),
+ authTag: cipher.getAuthTag().toString("base64"),
+ };
+}
+
+export function decryptSecret(enc: Encrypted): string {
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(enc.iv, "base64"));
+ decipher.setAuthTag(Buffer.from(enc.authTag, "base64"));
+ const pt = Buffer.concat([
+ decipher.update(Buffer.from(enc.ciphertext, "base64")),
+ decipher.final(),
+ ]);
+ return pt.toString("utf8");
+}
diff --git a/src/lib/current-user.ts b/src/lib/current-user.ts
new file mode 100644
index 0000000..6d38562
--- /dev/null
+++ b/src/lib/current-user.ts
@@ -0,0 +1,53 @@
+import { cookies } from "next/headers";
+import { SESSION_COOKIE, verifySession, type SessionPayload } from "./session";
+import { tierDef } from "./tiers";
+
+/** Read + verify the session for the current request (server components / routes). */
+export async function getCurrentUser(): Promise<SessionPayload | null> {
+ const store = await cookies();
+ const token = store.get(SESSION_COOKIE)?.value;
+ if (!token) return null;
+ return verifySession(token);
+}
+
+export type Role = "ADMIN" | "ANALYST" | "VIEWER";
+const RANK: Record<Role, number> = { VIEWER: 0, ANALYST: 1, ADMIN: 2 };
+
+/** True if the user's role meets or exceeds the required role. */
+export function hasRole(user: SessionPayload | null, required: Role): boolean {
+ if (!user) return false;
+ return (RANK[(user.role as Role) ?? "VIEWER"] ?? 0) >= RANK[required];
+}
+
+/**
+ * Resolve the current request's subscription tier from the DB (not the JWT — a
+ * subscription can change mid-session). Unauthenticated → FREE. ADMIN → PREMIUM
+ * (Steve always sees everything). Imported lazily to keep this edge-safe module light.
+ */
+export async function getCurrentTier(): Promise<import("@prisma/client").Tier> {
+ const user = await getCurrentUser();
+ if (!user) return "FREE";
+ if (user.role === "ADMIN") return "PREMIUM";
+ // The synthetic fleet-SSO session is Steve arriving via the fleet login — an
+ // internal operator, so show full data (money-math). It is still only ANALYST,
+ // so the ADMIN-gated credential surface stays closed.
+ if (user.sub === "fleet-sso") return "PREMIUM";
+ const { prisma } = await import("./db");
+ const row = await prisma.user.findUnique({ where: { id: user.sub }, select: { tier: true } });
+ return row?.tier ?? "FREE";
+}
+
+/**
+ * Whether the money-math (recommended max bid / ROI / valuations / opportunity
+ * scores) is visible for this request. Visibility is a function of the resolved
+ * subscription tier ONLY — every client (iOS app, browser, curl) sees identical
+ * data for a given tier. The FREE tier includes the full analysis, so a fresh
+ * install with no account and no purchase gets the same JSON a signed-in user or
+ * an anonymous browser gets. Nothing keys off the client, a header, or any other
+ * undisclosed signal.
+ */
+export async function moneyMathVisible(
+ tier: import("@prisma/client").Tier
+): Promise<boolean> {
+ return tierDef(tier).limits.showMoneyMath;
+}
diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts
new file mode 100644
index 0000000..1e90cab
--- /dev/null
+++ b/src/lib/dashboard.ts
@@ -0,0 +1,41 @@
+import { prisma } from "@/lib/db";
+
+export interface DashboardStats {
+ activeAuctions: number;
+ closingToday: number;
+ highestProfit: number;
+ highestRoi: number;
+ cashRequired: number; // sum of recommended max bids across active opportunities
+ expectedProfit: number; // sum of expected net profit
+ highestScore: number;
+ pendingResearch: number;
+}
+
+/** Aggregate the summary-card metrics in a single pass. */
+export async function getDashboardStats(): Promise<DashboardStats> {
+ const now = new Date();
+ const endOfDay = new Date(now);
+ endOfDay.setHours(23, 59, 59, 999);
+
+ const [activeAuctions, closingToday, pendingResearch, costAgg, topScore] = await Promise.all([
+ prisma.listing.count({ where: { listingStatus: "ACTIVE", closingAt: { gte: now } } }),
+ prisma.listing.count({ where: { listingStatus: "ACTIVE", closingAt: { gte: now, lte: endOfDay } } }),
+ prisma.listing.count({ where: { researchStatus: { in: ["PENDING", "QUEUED", "IN_PROGRESS"] } } }),
+ prisma.costBreakdown.aggregate({
+ _sum: { expectedNetProfit: true, recommendedMaxBid: true },
+ _max: { expectedNetProfit: true, roi: true },
+ }),
+ prisma.score.aggregate({ where: { profile: "OVERALL_OPPORTUNITY" }, _max: { value: true } }),
+ ]);
+
+ return {
+ activeAuctions,
+ closingToday,
+ highestProfit: Number(costAgg._max.expectedNetProfit ?? 0),
+ highestRoi: Number(costAgg._max.roi ?? 0),
+ cashRequired: Number(costAgg._sum.recommendedMaxBid ?? 0),
+ expectedProfit: Number(costAgg._sum.expectedNetProfit ?? 0),
+ highestScore: Number(topScore._max.value ?? 0),
+ pendingResearch,
+ };
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..84941e8
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,13 @@
+import { PrismaClient } from "@prisma/client";
+
+// Reuse a single PrismaClient across hot-reloads in dev to avoid exhausting
+// Postgres connections (Next.js re-evaluates modules on every change).
+const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
+
+export const prisma =
+ globalForPrisma.prisma ??
+ new PrismaClient({
+ log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
+ });
+
+if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
diff --git a/src/lib/digest-snapshot.ts b/src/lib/digest-snapshot.ts
new file mode 100644
index 0000000..1588270
--- /dev/null
+++ b/src/lib/digest-snapshot.ts
@@ -0,0 +1,134 @@
+import { prisma } from "@/lib/db";
+import { findHotDeals, type HotDeal, type Confidence } from "@/lib/hot-deals";
+import type { DigestSlot, Prisma } from "@prisma/client";
+
+// Freezes each digest run into a DigestSnapshot so the public /deals archive
+// renders a stable, indexable record instead of re-querying live listings
+// that expire. Only PUBLIC-SAFE display fields are serialized — exactly what
+// the free-tier digest email already shows, never internal cost math.
+
+export interface SnapshotDeal {
+ rank: number;
+ title: string;
+ source: string;
+ locationCity: string | null;
+ locationState: string | null;
+ currentBid: number;
+ recMax: number;
+ expectedSaleLow: number;
+ confidence: Confidence;
+ roiConservative: number;
+ roiCapped: boolean;
+ disclaimer: string;
+ closingAt: string | null;
+ sourceUrl: string | null;
+}
+
+export function digestDateString(d = new Date()): string {
+ // en-CA renders YYYY-MM-DD; PT is the digest's home timezone (6am/5pm sends).
+ return d.toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" });
+}
+
+export function currentSlot(d = new Date()): DigestSlot {
+ const hour = Number(
+ d.toLocaleString("en-US", { timeZone: "America/Los_Angeles", hour: "numeric", hour12: false }),
+ );
+ return hour < 12 ? "AM" : "PM";
+}
+
+export function toSnapshotDeals(deals: HotDeal[]): SnapshotDeal[] {
+ return deals.map((d, i) => ({
+ rank: i + 1,
+ title: d.listing.title,
+ source: d.listing.source,
+ locationCity: d.listing.locationCity,
+ locationState: d.listing.locationState,
+ currentBid: d.currentBid,
+ recMax: d.recMax,
+ expectedSaleLow: d.expectedSaleLow,
+ confidence: d.confidence,
+ roiConservative: d.roiConservative,
+ roiCapped: d.roiCapped,
+ disclaimer: d.disclaimer,
+ closingAt: d.closingAt ? new Date(d.closingAt).toISOString() : null,
+ sourceUrl: d.listing.sourceUrl,
+ }));
+}
+
+/** Persist a snapshot from already-fetched deals (used at digest send time). */
+export async function persistDigestSnapshot(slot: DigestSlot, deals: HotDeal[], date = digestDateString()) {
+ const snapshotDeals = toSnapshotDeals(deals);
+ const dealsJson = snapshotDeals as unknown as Prisma.InputJsonValue;
+ return prisma.digestSnapshot.upsert({
+ where: { date_slot: { date, slot } },
+ create: { date, slot, dealsJson, dealCount: snapshotDeals.length },
+ update: { dealsJson, dealCount: snapshotDeals.length },
+ });
+}
+
+/** Standalone capture: run the honesty-gated Top-10 and freeze it. */
+export async function captureDigestSnapshot(slot: DigestSlot = currentSlot()) {
+ const deals = await findHotDeals({ limit: 10 });
+ return persistDigestSnapshot(slot, deals);
+}
+
+// ── read side for the /deals archive pages ─────────────────────────────────
+
+export interface DigestEdition {
+ date: string;
+ slot: DigestSlot;
+ dealCount: number;
+ createdAt: Date;
+ deals: SnapshotDeal[];
+}
+
+function parseDeals(dealsJson: Prisma.JsonValue): SnapshotDeal[] {
+ return Array.isArray(dealsJson) ? (dealsJson as unknown as SnapshotDeal[]) : [];
+}
+
+function toEdition(r: { date: string; slot: DigestSlot; dealCount: number; createdAt: Date; dealsJson: Prisma.JsonValue }): DigestEdition {
+ return {
+ date: r.date,
+ slot: r.slot,
+ dealCount: r.dealCount,
+ createdAt: r.createdAt,
+ deals: parseDeals(r.dealsJson),
+ };
+}
+
+// Fabricated seed editions never reach the public archive. Schema-level flag,
+// not a delete-before-launch convention — opt in locally with SHOW_SEED_DIGESTS=1.
+function seedFilter(): { isSeed?: false } {
+ return process.env.SHOW_SEED_DIGESTS === "1" ? {} : { isSeed: false };
+}
+
+export async function listDigestEditions(limit = 60): Promise<DigestEdition[]> {
+ const rows = await prisma.digestSnapshot.findMany({
+ where: seedFilter(),
+ orderBy: [{ date: "desc" }, { slot: "asc" }],
+ take: limit,
+ });
+ return rows.map(toEdition);
+}
+
+export async function getDigestEditionsForDate(date: string): Promise<DigestEdition[]> {
+ const rows = await prisma.digestSnapshot.findMany({
+ where: { date, ...seedFilter() },
+ orderBy: { slot: "asc" },
+ });
+ return rows.map(toEdition);
+}
+
+export async function listDigestDates(): Promise<{ date: string; lastModified: Date }[]> {
+ const rows = await prisma.digestSnapshot.findMany({
+ where: seedFilter(),
+ select: { date: true, createdAt: true },
+ orderBy: { date: "desc" },
+ });
+ const byDate = new Map<string, Date>();
+ for (const r of rows) {
+ const prev = byDate.get(r.date);
+ if (!prev || r.createdAt > prev) byDate.set(r.date, r.createdAt);
+ }
+ return [...byDate.entries()].map(([date, lastModified]) => ({ date, lastModified }));
+}
diff --git a/src/lib/digest-top.ts b/src/lib/digest-top.ts
new file mode 100644
index 0000000..072b665
--- /dev/null
+++ b/src/lib/digest-top.ts
@@ -0,0 +1,32 @@
+import { queryListings, type ListingRow } from "@/lib/listings";
+
+// Shared "top ranked opportunities" selection — the single definition of how
+// the Top-10 digest (scripts/send-digest.ts) and the skeptic agent pick the
+// ranked head of the corpus, so the adversarial gate audits exactly what the
+// digest is about to email.
+
+export interface TopRankedOptions {
+ limit: number;
+ /**
+ * When true (digest behavior) only still-open auctions (closingAt in the
+ * future) are returned. The skeptic passes false so it can also catch
+ * stale rows — closed auctions still ranked as if live.
+ */
+ openOnly?: boolean;
+}
+
+/** ACTIVE listings ranked by Overall Opportunity, best first. */
+export async function topRankedOpportunities(opts: TopRankedOptions): Promise<ListingRow[]> {
+ const { limit, openOnly = true } = opts;
+ const { rows } = await queryListings({
+ profile: "OVERALL_OPPORTUNITY",
+ sort: "opportunityScore",
+ dir: "desc",
+ pageSize: Math.max(200, limit),
+ });
+ const now = Date.now();
+ const pool = openOnly
+ ? rows.filter((r) => r.closingAt && new Date(r.closingAt).getTime() > now)
+ : rows;
+ return pool.slice(0, limit);
+}
diff --git a/src/lib/fleet-sso.ts b/src/lib/fleet-sso.ts
new file mode 100644
index 0000000..427a034
--- /dev/null
+++ b/src/lib/fleet-sso.ts
@@ -0,0 +1,44 @@
+// Fleet single-sign-on cookie ("aafleet") — a stateless HMAC token shared
+// across every *.agentabrams.com site, issued by the fleet-sso service
+// (/var/www/fleet-sso/server.mjs). Format: "v1.<exp>.<sig>" where
+// sig = HMAC-SHA256(FLEET_SSO_SECRET, "v1." + exp) (hex)
+// and exp is a unix-seconds expiry. Because the cookie's Domain is
+// ".agentabrams.com", auctions already receives it — validating it here lets a
+// single fleet login carry into this app with no second sign-in.
+//
+// This mirrors the verifier's valid() exactly and is Edge-safe (Web Crypto,
+// no Node `crypto`), so it runs inside the Next.js middleware.
+
+export const FLEET_SSO_COOKIE = "aafleet";
+
+const TOKEN_RE = /^v1\.(\d+)\.([0-9a-f]{64})$/;
+
+function toHex(buf: ArrayBuffer): string {
+ return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+// Constant-time-ish compare of two equal-length hex strings.
+function safeEqualHex(a: string, b: string): boolean {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ return diff === 0;
+}
+
+export async function fleetSsoOk(token: string | undefined | null): Promise<boolean> {
+ const secret = process.env.FLEET_SSO_SECRET;
+ if (!secret || !token) return false;
+ const m = TOKEN_RE.exec(token);
+ if (!m) return false;
+ const exp = parseInt(m[1], 10);
+ if (!(exp > Math.floor(Date.now() / 1000))) return false; // expired
+ const key = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"],
+ );
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode("v1." + m[1]));
+ return safeEqualHex(toHex(mac), m[2]);
+}
diff --git a/src/lib/hot-deals.ts b/src/lib/hot-deals.ts
new file mode 100644
index 0000000..287ecc1
--- /dev/null
+++ b/src/lib/hot-deals.ts
@@ -0,0 +1,191 @@
+import { prisma } from "@/lib/db";
+
+// A HOT DEAL is not merely a cheap lot — it is a lot where BOTH sides check out:
+// BUY side — there is still headroom to bid profitably (recommendedMaxBid is
+// meaningfully above the current bid), the net profit and ROI clear
+// the bar, and the auction is still open (or a make-offer item).
+// SELL side — "real opportunity to sell before you buy": genuine resale demand
+// (demand score + probability of sale) and a valued expected sale.
+// This is the gate behind the 🔥 alert. Thresholds are env-tunable so Steve can
+// dial the signal without a code change.
+
+export interface HotDealCriteria {
+ minScore: number; // OVERALL_OPPORTUNITY value 0..100
+ minArbitrage: number; // arbitrage sub-score
+ minDemand: number; // demand sub-score (sell-side proof)
+ minProbSale: number; // Research.probabilityOfSale 0..1
+ minNetProfit: number; // $ expected net profit
+ minRoi: number; // ratio, 0.5 = 50%
+ minHeadroomPct: number; // (recMaxBid - currentBid)/recMaxBid, room to still bid & profit
+}
+
+function envNum(key: string, dflt: number): number {
+ const v = process.env[key];
+ const n = v == null ? NaN : Number(v);
+ return Number.isFinite(n) ? n : dflt;
+}
+
+export function defaultCriteria(): HotDealCriteria {
+ // Tuned 2026-07-10 against the live corpus: the Overall score is bimodal —
+ // a loose 75-81 cluster (~9% of all listings, inflated by the heuristic,
+ // no-AI valuation engine) and a small genuine elite at 82+. Anchoring at
+ // score≥82 surfaces only true standouts instead of flooding. Every threshold
+ // is env-overridable so Steve can loosen/tighten without a code change.
+ // NOTE: valuations are heuristic (optimistic) until real marketplace-comp
+ // calibration lands — treat ROI as directional, verify comps before bidding.
+ return {
+ minScore: envNum("HOT_MIN_SCORE", 82),
+ minArbitrage: envNum("HOT_MIN_ARBITRAGE", 72),
+ minDemand: envNum("HOT_MIN_DEMAND", 55),
+ minProbSale: envNum("HOT_MIN_PROB_SALE", 0.55),
+ minNetProfit: envNum("HOT_MIN_NET", 300),
+ minRoi: envNum("HOT_MIN_ROI", 0.75),
+ minHeadroomPct: envNum("HOT_MIN_HEADROOM_PCT", 0.2),
+ };
+}
+
+export type HotDeal = Awaited<ReturnType<typeof findHotDeals>>[number];
+
+const num = (d: unknown): number => (d == null ? 0 : Number(d));
+
+// ── HONESTY GATE ──────────────────────────────────────────────────────────
+// The valuation engine is heuristic and runs OPTIMISTIC (a "727% ROI" is not a
+// promise we can keep). Charging for alerts built on inflated ROI is a
+// chargeback/trust trap. So every alert is discounted by our CONFIDENCE in it
+// and always carries the evidence basis + a verify-comps disclaimer. We sell
+// "curated + de-duped + early", never a guaranteed number. Under-promise.
+export type Confidence = "LOW" | "MEDIUM" | "HIGH";
+
+// Confidence from REAL evidence: how many comparable sales back the estimate,
+// the modeled probability of sale, and whether we have used-market comps at all.
+export function assessConfidence(
+ comparableCount: number,
+ probSale: number,
+ hasUsedComps: boolean,
+): Confidence {
+ if (comparableCount >= 5 && probSale >= 0.7) return "HIGH";
+ if (comparableCount >= 2 && probSale >= 0.55 && hasUsedComps) return "MEDIUM";
+ return "LOW";
+}
+
+// Haircut applied to the optimistic sale/ROI by confidence — the number we
+// actually SHOW is the conservative low band, so reality tends to beat it.
+export const CONFIDENCE_HAIRCUT: Record<Confidence, number> = {
+ LOW: 0.5,
+ MEDIUM: 0.7,
+ HIGH: 0.9,
+};
+
+// Displayed ROI is capped so a heuristic outlier can never render as hype.
+export const ROI_DISPLAY_CAP = 2.0; // 200%
+
+/**
+ * Find ACTIVE listings that pass the HOT DEAL gate. If `onlyUnalerted`, skip
+ * ones already alerted (hotAlertedAt set) — the alerter uses this to notify
+ * each deal exactly once.
+ */
+export async function findHotDeals(opts: {
+ criteria?: HotDealCriteria;
+ onlyUnalerted?: boolean;
+ limit?: number;
+} = {}) {
+ const c = opts.criteria ?? defaultCriteria();
+ const now = new Date();
+
+ // Pull ACTIVE listings with a strong overall score, then apply the full gate
+ // in JS (mixes Score + CostBreakdown + Research fields across relations).
+ const listings = await prisma.listing.findMany({
+ where: {
+ listingStatus: "ACTIVE",
+ ...(opts.onlyUnalerted ? { hotAlertedAt: null } : {}),
+ scores: { some: { profile: "OVERALL_OPPORTUNITY", value: { gte: c.minScore } } },
+ },
+ include: {
+ research: true,
+ costBreakdown: true,
+ scores: true,
+ buyerLeads: true,
+ buyerPage: true,
+ comparables: true,
+ },
+ });
+
+ const deals = [];
+ for (const l of listings) {
+ const s = l.scores.find((x) => x.profile === "OVERALL_OPPORTUNITY");
+ const cb = l.costBreakdown;
+ const r = l.research;
+ if (!s || !cb) continue;
+
+ const currentBid = num(l.currentBid);
+ const recMax = num(cb.recommendedMaxBid);
+ const netProfit = num(cb.expectedNetProfit);
+ const roi = num(cb.roi);
+ const probSale = num(r?.probabilityOfSale);
+
+ // Auction must still be live: either open (closingAt in the future) or a
+ // make-offer/no-deadline item (null closingAt).
+ const open = !l.closingAt || new Date(l.closingAt).getTime() > now.getTime();
+ if (!open) continue;
+
+ // BUY-side headroom: recommended max bid must sit above the current bid by
+ // at least minHeadroomPct — i.e. you can still bid and profit.
+ const headroom = recMax > 0 ? (recMax - currentBid) / recMax : 0;
+
+ const pass =
+ s.value >= c.minScore &&
+ s.arbitrage >= c.minArbitrage &&
+ s.demand >= c.minDemand &&
+ probSale >= c.minProbSale &&
+ netProfit >= c.minNetProfit &&
+ roi >= c.minRoi &&
+ recMax > currentBid &&
+ headroom >= c.minHeadroomPct;
+
+ if (!pass) continue;
+
+ // ── Honesty Gate: discount the optimistic estimate by our confidence ──
+ const comparableCount = l.comparables.length;
+ const hasUsedComps = r?.usedLow != null || r?.usedSoldPrice != null || r?.usedHigh != null;
+ const confidence = assessConfidence(comparableCount, probSale, hasUsedComps);
+ const haircut = CONFIDENCE_HAIRCUT[confidence];
+ const expectedSale = num(r?.expectedSalePrice);
+ const expectedSaleLow = Math.round(expectedSale * haircut);
+ // Conservative ROI we actually SHOW: haircut by confidence, then capped.
+ const roiConservative = Math.min(roi * haircut, ROI_DISPLAY_CAP);
+ const roiCapped = roi > ROI_DISPLAY_CAP; // flag when the raw number was hype
+ const disclaimer = `Heuristic estimate${comparableCount ? ` from ${comparableCount} comp${comparableCount === 1 ? "" : "s"}` : " (thin comp data)"} — verify before bidding.`;
+
+ deals.push({
+ listing: l,
+ score: s,
+ cost: cb,
+ research: r,
+ currentBid,
+ recMax,
+ headroom,
+ netProfit,
+ roi,
+ probSale,
+ expectedSale,
+ // Honesty Gate outputs (what the paid alert should DISPLAY):
+ confidence,
+ expectedSaleLow,
+ roiConservative,
+ roiCapped,
+ disclaimer,
+ demandScore: s.demand,
+ comparableCount,
+ buyerLeadCount: l.buyerLeads.length,
+ buyerLeadTop: l.buyerLeads
+ .map((b) => num(b.offer))
+ .filter((x) => x > 0)
+ .sort((a, b) => b - a)[0] ?? 0,
+ closingAt: l.closingAt,
+ });
+ }
+
+ // Best first: highest score, then net profit.
+ deals.sort((a, b) => b.score.value - a.score.value || b.netProfit - a.netProfit);
+ return typeof opts.limit === "number" ? deals.slice(0, opts.limit) : deals;
+}
diff --git a/src/lib/listing-detail.test.ts b/src/lib/listing-detail.test.ts
new file mode 100644
index 0000000..e874ee6
--- /dev/null
+++ b/src/lib/listing-detail.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { Prisma } from "@prisma/client";
+import { decimalsToNumbers } from "./listing-detail";
+
+// Regression test for TK-10279: the listing DETAIL endpoint (unlike the LIST
+// endpoint's flattenListing(), which already ran every Decimal through
+// num()/numN()) used to return raw Prisma Decimal instances straight to
+// NextResponse.json(). JSON.stringify() serializes a Decimal via its own
+// toJSON() -> a STRING ("103.93"), which the mobile client's Number.isFinite()
+// render guards (fmtUSD/fmtPct/fmtScore) correctly treat as non-finite and
+// render "—" -- so a fully-researched listing showed an all-null Valuation
+// table + null Recommended Max Bid on the detail screen while the LIST screen
+// (same listing, same underlying data) showed real numbers. This test locks
+// in the fix: decimalsToNumbers() must turn every Decimal in the object graph
+// into a real `number` so it survives a JSON round-trip as a JSON number, not
+// a string.
+describe("decimalsToNumbers (TK-10279 detail-screen null-valuation regression)", () => {
+ it("converts a top-level Decimal to a real number", () => {
+ const out = decimalsToNumbers(new Prisma.Decimal("103.93"));
+ expect(typeof out).toBe("number");
+ expect(out).toBe(103.93);
+ });
+
+ it("converts nested Decimals inside relation objects (costBreakdown / research)", () => {
+ const listing = {
+ id: "l1",
+ currentBid: new Prisma.Decimal("10"),
+ research: {
+ newRetail: new Prisma.Decimal("900"),
+ avgRetail: new Prisma.Decimal("828"),
+ usedLow: null,
+ },
+ costBreakdown: {
+ recommendedMaxBid: new Prisma.Decimal("103.93"),
+ expectedNetProfit: new Prisma.Decimal("182.96"),
+ roi: 1.91, // Float column — already a plain number, must pass through unchanged
+ },
+ scores: [{ value: 82.07, risk: "MEDIUM" }],
+ };
+
+ const fixed = decimalsToNumbers(listing);
+
+ expect(typeof fixed.currentBid).toBe("number");
+ expect(fixed.currentBid).toBe(10);
+ expect(typeof fixed.research!.newRetail).toBe("number");
+ expect(fixed.research!.newRetail).toBe(900);
+ expect(fixed.research!.usedLow).toBeNull();
+ expect(typeof fixed.costBreakdown!.recommendedMaxBid).toBe("number");
+ expect(fixed.costBreakdown!.recommendedMaxBid).toBe(103.93);
+ expect(fixed.costBreakdown!.roi).toBe(1.91);
+ expect(fixed.scores[0].value).toBe(82.07);
+ });
+
+ it("survives a JSON.stringify round-trip as a JSON number, not a string (the actual client-visible bug)", () => {
+ const raw = { costBreakdown: { recommendedMaxBid: new Prisma.Decimal("4736.72") } };
+
+ // BEFORE the fix: JSON.stringify(raw) directly would produce
+ // '{"costBreakdown":{"recommendedMaxBid":"4736.72"}}' — a STRING.
+ const buggyRoundTrip = JSON.parse(JSON.stringify(raw));
+ expect(typeof buggyRoundTrip.costBreakdown.recommendedMaxBid).toBe("string");
+ expect(Number.isFinite(buggyRoundTrip.costBreakdown.recommendedMaxBid)).toBe(false);
+
+ // AFTER the fix: decimalsToNumbers() first, then serialize.
+ const fixedRoundTrip = JSON.parse(JSON.stringify(decimalsToNumbers(raw)));
+ expect(typeof fixedRoundTrip.costBreakdown.recommendedMaxBid).toBe("number");
+ expect(Number.isFinite(fixedRoundTrip.costBreakdown.recommendedMaxBid)).toBe(true);
+ expect(fixedRoundTrip.costBreakdown.recommendedMaxBid).toBe(4736.72);
+ });
+
+ it("leaves non-Decimal values (strings, Dates, null, arrays) untouched", () => {
+ const now = new Date("2026-09-03T00:00:00Z");
+ const out = decimalsToNumbers({
+ title: "Late 2013 Apple iMac 21.5\"",
+ sourceAuctionId: "29250-189",
+ closingAt: now,
+ imageUrls: ["https://example.com/a.jpg"],
+ description: null,
+ });
+ expect(out.title).toBe("Late 2013 Apple iMac 21.5\"");
+ expect(out.sourceAuctionId).toBe("29250-189");
+ expect(out.closingAt).toBe(now);
+ expect(out.imageUrls).toEqual(["https://example.com/a.jpg"]);
+ expect(out.description).toBeNull();
+ });
+});
diff --git a/src/lib/listing-detail.ts b/src/lib/listing-detail.ts
new file mode 100644
index 0000000..9b31dfc
--- /dev/null
+++ b/src/lib/listing-detail.ts
@@ -0,0 +1,81 @@
+import { prisma } from "@/lib/db";
+import { getCurrentTier, moneyMathVisible } from "@/lib/current-user";
+import { Prisma, type Tier } from "@prisma/client";
+
+// Single source of truth for the listing DETAIL payload and its tier gate.
+// Visibility depends on the resolved tier only, never on the client type. All
+// current tiers (FREE included) show the full analysis, so the redaction below
+// is dormant — retained so a future gated tier can null the money-math
+// relations (costBreakdown, research valuations, scores, comparables, buyer
+// leads) consistently with the list endpoint.
+
+export type ListingDetail = NonNullable<Awaited<ReturnType<typeof queryListingDetail>>>;
+
+function queryListingDetail(id: string) {
+ return prisma.listing.findUnique({
+ where: { id },
+ include: {
+ research: true,
+ costBreakdown: true,
+ scores: { orderBy: { value: "desc" } },
+ comparables: { orderBy: { price: "desc" } },
+ notes: { orderBy: { createdAt: "desc" } },
+ events: { orderBy: { createdAt: "desc" }, take: 20 },
+ buyerLeads: { orderBy: { createdAt: "desc" } },
+ buyerPage: true,
+ outcome: true,
+ },
+ });
+}
+
+// Root-cause fix (TK-10279, 2026-09-03): every money-math field on Listing /
+// Research / CostBreakdown is a Prisma Decimal. JSON.stringify() (what
+// NextResponse.json() uses) serializes a Decimal via its own toJSON(), which
+// returns a STRING (e.g. "103.93"), not a JSON number. The LIST endpoint
+// (src/lib/listings.ts flattenListing()) already runs every Decimal through
+// num()/numN() so it emits real numbers — but this DETAIL path returned the
+// raw Prisma object untouched, so its JSON carried numeric-looking strings.
+// The mobile client's Number.isFinite() render guards (fmtUSD/fmtPct/fmtScore)
+// correctly reject a string as non-finite and render "—" — so a fully
+// populated listing (proven identical to the list's $183 net profit / 191%
+// ROI numbers) showed an all-null Valuation table + a null Recommended Max
+// Bid + "Current bid: —" on the detail screen only. Recursively converting
+// every Decimal to a plain number before the route serializes it makes the
+// detail payload's JSON shape match the list's (and match the mobile client's
+// own `ListingDetail`/`CostBreakdown`/`Research` types, which already declare
+// these fields as `number | null`).
+export function decimalsToNumbers<T>(value: T): T {
+ if (value instanceof Prisma.Decimal) return value.toNumber() as unknown as T;
+ if (Array.isArray(value)) return value.map((v) => decimalsToNumbers(v)) as unknown as T;
+ if (value instanceof Date) return value;
+ if (value && typeof value === "object") {
+ const out: Record<string, unknown> = {};
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
+ out[k] = decimalsToNumbers(v);
+ }
+ return out as T;
+ }
+ return value;
+}
+
+export async function getGatedListingDetail(
+ id: string
+): Promise<{ listing: ListingDetail | null; tier: Tier; gated: boolean }> {
+ const tier = await getCurrentTier();
+ const gated = !(await moneyMathVisible(tier));
+ const raw = await queryListingDetail(id);
+ const listing = raw ? decimalsToNumbers(raw) : raw;
+ if (!listing || !gated) return { listing, tier, gated };
+
+ const redacted: ListingDetail = {
+ ...listing,
+ research: null,
+ costBreakdown: null,
+ scores: [],
+ comparables: [],
+ buyerLeads: [],
+ buyerPage: null,
+ outcome: null,
+ };
+ return { listing: redacted, tier, gated };
+}
diff --git a/src/lib/listings.ts b/src/lib/listings.ts
new file mode 100644
index 0000000..f7a30dd
--- /dev/null
+++ b/src/lib/listings.ts
@@ -0,0 +1,243 @@
+import { prisma } from "@/lib/db";
+import type { Prisma } from "@prisma/client";
+
+// Flat row shape backing the dashboard table — every sortable column the spec
+// lists, denormalized from Listing + Research + CostBreakdown + the chosen Score.
+export interface ListingRow {
+ id: string;
+ source: string;
+ sourceAuctionId: string;
+ sourceUrl: string | null;
+ title: string;
+ category: string | null;
+ manufacturer: string | null;
+ model: string | null;
+ condition: string;
+ quantity: number;
+ currentBid: number;
+ currentCost: number; // bid + premium + tax (acquisition-in)
+ recommendedMaxBid: number;
+ retailLow: number | null;
+ retailAverage: number | null;
+ retailHigh: number | null;
+ usedLow: number | null;
+ usedAverage: number | null;
+ usedHigh: number | null;
+ wholesale: number | null;
+ liquidation: number | null;
+ sellNow: number | null;
+ value7Day: number | null;
+ value30Day: number | null;
+ value90Day: number | null;
+ expectedSale: number | null;
+ shipping: number;
+ freight: number;
+ repairs: number;
+ marketplaceFees: number;
+ netProfit: number;
+ roi: number;
+ risk: string;
+ confidence: number | null;
+ opportunityScore: number;
+ arbitrageScore: number;
+ demandScore: number;
+ velocityScore: number;
+ logisticsScore: number;
+ conditionScore: number;
+ competitionScore: number;
+ buyerScore: number;
+ dropShip: string;
+ closingAt: string | null;
+ researchStatus: string;
+ imageUrl: string | null;
+}
+
+type FullListing = Prisma.ListingGetPayload<{
+ include: { research: true; costBreakdown: true; scores: true };
+}>;
+
+const num = (d: Prisma.Decimal | number | null | undefined): number =>
+ d == null ? 0 : typeof d === "number" ? d : Number(d);
+const numN = (d: Prisma.Decimal | number | null | undefined): number | null =>
+ d == null ? null : typeof d === "number" ? d : Number(d);
+
+export function flattenListing(l: FullListing, profile = "OVERALL_OPPORTUNITY"): ListingRow {
+ const r = l.research;
+ const c = l.costBreakdown;
+ const s = l.scores.find((x) => x.profile === profile) ?? l.scores[0];
+ const currentCost = c ? num(c.winningBid) + num(c.buyerPremium) + num(c.salesTax) : num(l.currentBid);
+ return {
+ id: l.id,
+ source: l.source,
+ sourceAuctionId: l.sourceAuctionId,
+ sourceUrl: l.sourceUrl,
+ title: l.title,
+ category: l.category,
+ manufacturer: l.manufacturer,
+ model: l.model,
+ condition: l.condition,
+ quantity: l.quantity,
+ currentBid: num(l.currentBid),
+ currentCost,
+ recommendedMaxBid: num(c?.recommendedMaxBid),
+ retailLow: numN(r?.usedLow),
+ retailAverage: numN(r?.avgRetail),
+ retailHigh: numN(r?.newRetail),
+ usedLow: numN(r?.usedLow),
+ usedAverage: numN(r?.usedSoldPrice),
+ usedHigh: numN(r?.usedHigh),
+ wholesale: numN(r?.wholesaleValue),
+ liquidation: numN(r?.liquidationValue),
+ sellNow: numN(r?.sellTodayValue),
+ value7Day: numN(r?.value7Day),
+ value30Day: numN(r?.value30Day),
+ value90Day: numN(r?.value90Day),
+ expectedSale: numN(r?.expectedSalePrice),
+ shipping: num(c?.shipping),
+ freight: num(c?.freight),
+ repairs: num(c?.repairs),
+ marketplaceFees: num(c?.marketplaceFees),
+ netProfit: num(c?.expectedNetProfit),
+ roi: num(c?.roi),
+ risk: s?.risk ?? "MEDIUM",
+ confidence: numN(r?.confidenceScore),
+ opportunityScore: s?.value ?? 0,
+ arbitrageScore: s?.arbitrage ?? 0,
+ demandScore: s?.demand ?? 0,
+ velocityScore: s?.velocity ?? 0,
+ logisticsScore: s?.logistics ?? 0,
+ conditionScore: s?.condition ?? 0,
+ competitionScore: s?.competition ?? 0,
+ buyerScore: s?.buyer ?? 0,
+ dropShip: s?.dropShip ?? "MODERATE",
+ closingAt: l.closingAt ? l.closingAt.toISOString() : null,
+ researchStatus: l.researchStatus,
+ imageUrl: l.imageUrls[0] ?? null,
+ };
+}
+
+export interface QueryParams {
+ search?: string;
+ source?: string;
+ category?: string;
+ condition?: string;
+ risk?: string;
+ closingWithinHours?: number;
+ sort?: keyof ListingRow;
+ dir?: "asc" | "desc";
+ page?: number;
+ pageSize?: number;
+ profile?: string;
+ // Listing lifecycle filter. Defaults to ACTIVE so ended/removed (dead)
+ // listings never show on the live grid. Pass "ALL" to include everything.
+ status?: string;
+}
+
+// Listing scalar columns that map 1-to-1 to a DB field and can therefore be
+// pushed into Prisma's orderBy. Everything else (scores, risk, computed cost
+// fields) lives on joined relations and must be sorted in JS.
+const NATIVE_SORT_COLUMNS = new Set<keyof ListingRow>([
+ "currentBid",
+ "closingAt",
+ "title",
+ "source",
+ "sourceAuctionId",
+ "category",
+ "manufacturer",
+ "model",
+ "condition",
+ "quantity",
+ "researchStatus",
+]);
+
+export async function queryListings(params: QueryParams) {
+ const {
+ search,
+ source,
+ category,
+ condition,
+ risk,
+ closingWithinHours,
+ sort = "opportunityScore",
+ dir = "desc",
+ page = 1,
+ pageSize = 50,
+ profile = "OVERALL_OPPORTUNITY",
+ status = "ACTIVE",
+ } = params;
+
+ const where: Prisma.ListingWhereInput = {};
+ if (status && status !== "ALL") {
+ where.listingStatus = status as Prisma.ListingWhereInput["listingStatus"];
+ }
+ if (source) where.source = source as Prisma.ListingWhereInput["source"];
+ if (condition) where.condition = condition as Prisma.ListingWhereInput["condition"];
+ if (category) where.category = { contains: category, mode: "insensitive" };
+ if (search) {
+ where.OR = [
+ { title: { contains: search, mode: "insensitive" } },
+ { manufacturer: { contains: search, mode: "insensitive" } },
+ { model: { contains: search, mode: "insensitive" } },
+ { sourceAuctionId: { contains: search, mode: "insensitive" } },
+ ];
+ }
+ if (closingWithinHours) {
+ where.closingAt = { lte: new Date(Date.now() + closingWithinHours * 3_600_000), gte: new Date() };
+ }
+
+ // DB-side sort + pagination path: only when the sort column is a native
+ // Listing scalar AND no risk filter is active (risk is derived from the
+ // Score relation and cannot be pushed to the DB without a schema change).
+ const canPaginateAtDb = NATIVE_SORT_COLUMNS.has(sort) && !risk;
+
+ if (canPaginateAtDb) {
+ const [rawRows, total] = await Promise.all([
+ prisma.listing.findMany({
+ where,
+ include: { research: true, costBreakdown: true, scores: true },
+ // nulls first on asc / last on desc mirrors the JS fallback's
+ // String(v ?? "") ordering, so the DB path is order-equivalent.
+ orderBy: { [sort]: { sort: dir, nulls: dir === "asc" ? "first" : "last" } } as Prisma.ListingOrderByWithRelationInput,
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ prisma.listing.count({ where }),
+ ]);
+ const rows = rawRows.map((l) => flattenListing(l, profile));
+ return { rows, total, page, pageSize };
+ }
+
+ // JS-side sort + pagination fallback: used when sort is a relation/computed
+ // field (opportunityScore, arbitrageScore, netProfit, roi, risk, etc.) or
+ // when a risk filter is active. A hard cap prevents unbounded memory load.
+ // TODO(perf): denormalize score columns (value, arbitrage, risk, …) onto
+ // Listing to enable DB-side sort/pagination for score-based sorts — needs a
+ // gated migration adding columns + a backfill job.
+ const CAP = 5000;
+ const all = await prisma.listing.findMany({
+ where,
+ include: { research: true, costBreakdown: true, scores: true },
+ take: CAP,
+ });
+ if (all.length === CAP) {
+ // Not silent: past the cap, total (rows.length) undercounts for this
+ // filter+sort. The durable fix is the denormalization TODO above.
+ console.warn(`[queryListings] hit ${CAP}-row cap (sort=${sort}, risk=${risk ?? "none"}); total may undercount`);
+ }
+
+ let rows = all.map((l) => flattenListing(l, profile));
+ if (risk) rows = rows.filter((r) => r.risk === risk);
+
+ rows.sort((a, b) => {
+ const av = a[sort];
+ const bv = b[sort];
+ if (typeof av === "number" && typeof bv === "number") return dir === "asc" ? av - bv : bv - av;
+ const as = String(av ?? "");
+ const bs = String(bv ?? "");
+ return dir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
+ });
+
+ const total = rows.length;
+ const start = (page - 1) * pageSize;
+ return { rows: rows.slice(start, start + pageSize), total, page, pageSize };
+}
diff --git a/src/lib/marketplace-links.test.ts b/src/lib/marketplace-links.test.ts
new file mode 100644
index 0000000..c7aec17
--- /dev/null
+++ b/src/lib/marketplace-links.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { searchQuery, auctionNetworkLinks, boardLinks, internationalNetworkLinks, internationalTopLinks } from "./marketplace-links";
+
+describe("searchQuery", () => {
+ it("prefers manufacturer + model and strips lot/qty noise", () => {
+ expect(searchQuery({ title: "Computer Tables (4 EA)", manufacturer: "Herman Miller", model: "Aeron B" })).toBe(
+ "Herman Miller Aeron B",
+ );
+ expect(searchQuery({ title: "HOSPITAL STRECHERS (1 LOT)" })).toBe("HOSPITAL STRECHERS");
+ expect(searchQuery({ title: "Lot of 12 Apple MacBook Pro 14" })).toContain("Apple MacBook Pro 14");
+ });
+
+ it("ignores generic manufacturer/model values", () => {
+ expect(searchQuery({ title: "Mixed Metals Scrap", manufacturer: "Assorted", model: "Various" })).toBe(
+ "Mixed Metals Scrap",
+ );
+ });
+});
+
+describe("link builders", () => {
+ it("produces auction + resale + retail links, encoded", () => {
+ const links = auctionNetworkLinks({ title: "Toyota Forklift", category: "Industrial" });
+ const labels = links.map((l) => l.label);
+ expect(labels).toContain("eBay Sold");
+ expect(labels).toContain("GovDeals");
+ expect(labels).toContain("MachineryTrader"); // category specialist
+ expect(links.every((l) => l.url.includes("Toyota") || l.url.includes("Forklift"))).toBe(true);
+ });
+
+ it("adds DOTmed for medical items and produces board links", () => {
+ const med = auctionNetworkLinks({ title: "Patient Transfer Chair", category: "Medical" });
+ expect(med.map((l) => l.label)).toContain("DOTmed");
+ const boards = boardLinks({ title: "Patient Transfer Chair" });
+ expect(boards.map((l) => l.label)).toEqual(
+ expect.arrayContaining(["Reddit", "r/Flipping", "Facebook Marketplace", "Discord (Disboard)"]),
+ );
+ });
+
+ it("covers all five international regions with encoded queries", () => {
+ const intl = internationalNetworkLinks({ title: "Toyota Forklift" });
+ expect(intl.map((r) => r.region)).toEqual(["Europe", "Japan", "Hong Kong", "Australia", "China"]);
+ const allUrls = intl.flatMap((r) => r.links.map((l) => l.url));
+ expect(allUrls.some((u) => u.includes("yahoo.co.jp"))).toBe(true);
+ expect(allUrls.some((u) => u.includes("taobao.com"))).toBe(true);
+ expect(allUrls.some((u) => u.includes("graysonline.com"))).toBe(true);
+ expect(allUrls.every((u) => u.includes("Toyota") || u.includes("Forklift"))).toBe(true);
+ expect(internationalTopLinks({ title: "Toyota Forklift" })).toHaveLength(4);
+ });
+});
diff --git a/src/lib/marketplace-links.ts b/src/lib/marketplace-links.ts
new file mode 100644
index 0000000..ad2ddc8
--- /dev/null
+++ b/src/lib/marketplace-links.ts
@@ -0,0 +1,138 @@
+// Build deep-search links to (a) other auction/resale marketplaces and (b)
+// social "goods-needed"/flipping boards, from a listing's identifying terms.
+// All $0, no API keys — each link opens the network's live results for the item.
+
+export interface LinkChip {
+ label: string;
+ url: string;
+ group: "auction" | "resale" | "retail" | "board";
+}
+
+interface Named {
+ title: string;
+ manufacturer?: string | null;
+ model?: string | null;
+ category?: string | null;
+}
+
+/** Best short search query for an item: prefer brand+model, strip lot/qty noise. */
+export function searchQuery(l: Named): string {
+ const generic = /^(assorted|mixed|various|lot|n\/a|unknown|misc)/i;
+ const parts: string[] = [];
+ if (l.manufacturer && !generic.test(l.manufacturer)) parts.push(l.manufacturer);
+ if (l.model && !generic.test(l.model)) parts.push(l.model);
+ let q = parts.join(" ").trim();
+ if (!q) q = l.title;
+ // Strip lot/quantity noise: "(4 EA)", "Lot of 12", "1 LOT", trailing counts.
+ q = q
+ .replace(/\([^)]*\)/g, " ")
+ .replace(/\b(lot of|qty|quantity)\b\s*\d*/gi, " ")
+ .replace(/\b\d+\s*(ea|each|lot|pcs?|units?)\b/gi, " ")
+ .replace(/\bDEMIL\b/gi, " ")
+ .replace(/[^\w\s.\-/]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+ return q.split(/\s+/).slice(0, 7).join(" ");
+}
+
+const enc = (s: string) => encodeURIComponent(s);
+
+/** Actual offers/comps on other auction + resale + retail networks. */
+export function auctionNetworkLinks(l: Named): LinkChip[] {
+ const q = searchQuery(l);
+ const e = enc(q);
+ const links: LinkChip[] = [
+ { label: "eBay Sold", url: `https://www.ebay.com/sch/i.html?_nkw=${e}&LH_Sold=1&LH_Complete=1`, group: "resale" },
+ { label: "eBay Active", url: `https://www.ebay.com/sch/i.html?_nkw=${e}`, group: "resale" },
+ { label: "GovDeals", url: `https://www.govdeals.com/search?kWord=${e}`, group: "auction" },
+ { label: "AllSurplus", url: `https://www.allsurplus.com/search?query=${e}`, group: "auction" },
+ { label: "Public Surplus", url: `https://www.publicsurplus.com/sms/browse/search?keyword=${e}`, group: "auction" },
+ { label: "Municibid", url: `https://municibid.com/Browse?search=${e}`, group: "auction" },
+ { label: "Google Shopping", url: `https://www.google.com/search?tbm=shop&q=${e}`, group: "retail" },
+ ];
+ // Category-specialist venues.
+ const cat = `${l.category ?? ""} ${l.title}`.toLowerCase();
+ if (/forklift|generator|welder|lathe|cnc|scissor|mower|tractor|compressor|industrial|machin/.test(cat)) {
+ links.push({ label: "MachineryTrader", url: `https://www.machinerytrader.com/listings/search?keywords=${e}`, group: "resale" });
+ }
+ if (/lab|microscope|centrifuge|analyzer|hplc|spectro|scientific/.test(cat)) {
+ links.push({ label: "LabX", url: `https://www.labx.com/search?q=${e}`, group: "resale" });
+ }
+ if (/medical|dental|patient|exam|surgical|hospital|stretcher|vital/.test(cat)) {
+ links.push({ label: "DOTmed", url: `https://www.dotmed.com/equipment/search?q=${e}`, group: "resale" });
+ }
+ return links;
+}
+
+export interface RegionLinks {
+ region: string;
+ links: LinkChip[];
+}
+
+/** International resale + auction venues to check an item's price abroad. */
+export function internationalNetworkLinks(l: Named): RegionLinks[] {
+ const e = enc(searchQuery(l));
+ return [
+ {
+ region: "Europe",
+ links: [
+ { label: "eBay UK", url: `https://www.ebay.co.uk/sch/i.html?_nkw=${e}`, group: "resale" },
+ { label: "eBay Germany", url: `https://www.ebay.de/sch/i.html?_nkw=${e}`, group: "resale" },
+ { label: "Catawiki", url: `https://www.catawiki.com/en/s?q=${e}`, group: "auction" },
+ { label: "Troostwijk", url: `https://www.troostwijkauctions.com/en/l?searchQuery=${e}`, group: "auction" },
+ ],
+ },
+ {
+ region: "Japan",
+ links: [
+ { label: "Yahoo! Auctions JP", url: `https://auctions.yahoo.co.jp/search/search?p=${e}`, group: "resale" },
+ { label: "Mercari JP", url: `https://jp.mercari.com/search?keyword=${e}`, group: "resale" },
+ ],
+ },
+ {
+ region: "Hong Kong",
+ links: [{ label: "Carousell HK", url: `https://www.carousell.com.hk/search/${e}`, group: "resale" }],
+ },
+ {
+ region: "Australia",
+ links: [
+ { label: "GraysOnline", url: `https://www.graysonline.com/search?keywords=${e}`, group: "auction" },
+ { label: "Pickles", url: `https://www.pickles.com.au/search?q=${e}`, group: "auction" },
+ { label: "eBay AU", url: `https://www.ebay.com.au/sch/i.html?_nkw=${e}`, group: "resale" },
+ ],
+ },
+ {
+ region: "China",
+ links: [
+ { label: "Taobao", url: `https://s.taobao.com/search?q=${e}`, group: "resale" },
+ { label: "JD Auction", url: `https://auction.jd.com/searchList.html?keyword=${e}`, group: "auction" },
+ { label: "Alibaba", url: `https://www.alibaba.com/trade/search?SearchText=${e}`, group: "retail" },
+ ],
+ },
+ ];
+}
+
+/** Flat top-N international chips (for the compact dashboard column). */
+export function internationalTopLinks(l: Named): LinkChip[] {
+ const e = enc(searchQuery(l));
+ return [
+ { label: "eBay UK", url: `https://www.ebay.co.uk/sch/i.html?_nkw=${e}`, group: "resale" },
+ { label: "Yahoo! JP", url: `https://auctions.yahoo.co.jp/search/search?p=${e}`, group: "resale" },
+ { label: "Grays AU", url: `https://www.graysonline.com/search?keywords=${e}`, group: "auction" },
+ { label: "Taobao CN", url: `https://s.taobao.com/search?q=${e}`, group: "resale" },
+ ];
+}
+
+/** Social "goods-needed"/flipping/ISO boards + local marketplaces. */
+export function boardLinks(l: Named): LinkChip[] {
+ const q = searchQuery(l);
+ const e = enc(q);
+ return [
+ { label: "Reddit", url: `https://www.reddit.com/search/?q=${e}`, group: "board" },
+ { label: "r/Flipping", url: `https://www.reddit.com/r/Flipping/search/?q=${e}&restrict_sr=1`, group: "board" },
+ { label: "Reddit ISO/Wanted", url: `https://www.reddit.com/search/?q=${enc(`${q} ISO OR wanted OR "looking for"`)}`, group: "board" },
+ { label: "Facebook Marketplace", url: `https://www.facebook.com/marketplace/search/?query=${e}`, group: "board" },
+ { label: "Craigslist", url: `https://www.craigslist.org/search/sss?query=${e}`, group: "board" },
+ { label: "Discord (Disboard)", url: `https://disboard.org/search?keyword=${e}`, group: "board" },
+ ];
+}
diff --git a/src/lib/money-math-visibility.test.ts b/src/lib/money-math-visibility.test.ts
new file mode 100644
index 0000000..eafd783
--- /dev/null
+++ b/src/lib/money-math-visibility.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { moneyMathVisible } from "./current-user";
+import { TIERS, TIER_ORDER } from "./tiers";
+
+// Guideline 5.6 regression lock (TK-10279). The app was rejected because
+// money-math visibility keyed off an undisclosed client signal (the presence of
+// any Authorization header). The fix removed all client-sniffing: visibility is
+// a pure function of the resolved tier. These tests fail the moment anyone
+// reintroduces a client/header-dependent branch or re-gates the FREE tier.
+describe("money-math visibility (Guideline 5.6 lock)", () => {
+ it("moneyMathVisible depends ONLY on the tier argument — no request/header input", () => {
+ // A pure function of one argument cannot vary by client. Same tier in ⇒
+ // same answer out, every call.
+ expect(moneyMathVisible.length).toBe(1);
+ });
+
+ it("the FREE tier shows the full analysis, so a fresh anonymous install is complete", async () => {
+ expect(TIERS.FREE.limits.showMoneyMath).toBe(true);
+ expect(await moneyMathVisible("FREE")).toBe(true);
+ });
+
+ it("every tier shows money-math identically (nothing is client-specific)", async () => {
+ for (const t of TIER_ORDER) {
+ expect(await moneyMathVisible(t)).toBe(true);
+ }
+ });
+});
diff --git a/src/lib/newsletter.ts b/src/lib/newsletter.ts
new file mode 100644
index 0000000..5090442
--- /dev/null
+++ b/src/lib/newsletter.ts
@@ -0,0 +1,141 @@
+import { randomBytes } from "node:crypto";
+import { appendFile, mkdir } from "node:fs/promises";
+import path from "node:path";
+
+// Newsletter email dispatch — TEST mode by default.
+//
+// Env placeholders (all optional until a live send is approved):
+// NEWSLETTER_SEND_MODE "test" (default) | "live". Live additionally
+// requires NEWSLETTER_SEND_APPROVAL_TOKEN to be
+// set — both must be present or we stay in test.
+// NEWSLETTER_SEND_APPROVAL_TOKEN Steve-issued token gating live sends.
+// NEWSLETTER_FROM_NAME Display name for the From header.
+// NEWSLETTER_FROM_EMAIL From address (live wiring TBD via George/SMTP).
+// NEWSLETTER_MAILING_ADDRESS CAN-SPAM physical postal address (required in
+// every commercial email footer before go-live).
+// NEWSLETTER_BASE_URL Public origin for confirm/unsubscribe links;
+// falls back to the request origin.
+//
+// In TEST mode every would-be email is written to the console AND appended to
+// logs/newsletter-outbox.jsonl. LIVE mode (both NEWSLETTER_SEND_MODE=live and
+// NEWSLETTER_SEND_APPROVAL_TOKEN set) sends via George /api/send; both modes
+// append a redacted record to the outbox as the audit trail.
+
+export interface NewsletterEmail {
+ to: string;
+ subject: string;
+ text: string;
+}
+
+export function newsletterSendMode(): "test" | "live" {
+ const live =
+ process.env.NEWSLETTER_SEND_MODE === "live" &&
+ !!process.env.NEWSLETTER_SEND_APPROVAL_TOKEN;
+ return live ? "live" : "test";
+}
+
+const OUTBOX = path.join(process.cwd(), "logs", "newsletter-outbox.jsonl");
+
+// Confirm/unsubscribe tokens are live single-use credentials — anything that
+// lands in a log file must carry only a redacted prefix, never the full token.
+export function redactTokens(text: string): string {
+ return text.replace(/(token=)([A-Za-z0-9_-]{9,})/g, (_, k, v) => `${k}${v.slice(0, 8)}…`);
+}
+
+export async function sendNewsletterEmail(email: NewsletterEmail): Promise<void> {
+ const record = {
+ ...email,
+ text: redactTokens(email.text),
+ mode: newsletterSendMode(),
+ fromName: process.env.NEWSLETTER_FROM_NAME || "GovArbitrage Digest (unset)",
+ mailingAddress: process.env.NEWSLETTER_MAILING_ADDRESS || "(NEWSLETTER_MAILING_ADDRESS unset)",
+ at: new Date().toISOString(),
+ };
+
+ if (record.mode === "live") {
+ // Live transport: George /api/send (approved 2026-07-13). External sends
+ // require the X-Send-Approval token — George fail-closes without it. The
+ // transport gets `email.text` (full tokens); everything logged is redacted.
+ const base = process.env.GEORGE_URL;
+ const auth = process.env.GEORGE_BASIC_AUTH;
+ const sendToken = process.env.GEORGE_EXTERNAL_SEND_TOKEN;
+ if (!base || !auth || !sendToken) {
+ throw new Error("Live send needs GEORGE_URL, GEORGE_BASIC_AUTH, and GEORGE_EXTERNAL_SEND_TOKEN.");
+ }
+ const HTML_ESCAPE: Record<string, string> = { "&": "&", "<": "<", ">": ">" };
+ const html = `<pre style="font-family:ui-monospace,Menlo,monospace;font-size:14px;white-space:pre-wrap">${email.text
+ .replace(/[&<>]/g, (c) => HTML_ESCAPE[c] ?? c)
+ .replace(/(https?:\/\/[^\s]+)/g, '<a href="$1">$1</a>')}</pre>`;
+ const res = await fetch(`${base}/api/send`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Basic ${auth}`,
+ "X-Send-Approval": sendToken,
+ },
+ body: JSON.stringify({
+ account: process.env.NEWSLETTER_GEORGE_ACCOUNT || "steve-office",
+ to: email.to,
+ subject: email.subject,
+ body: html,
+ }),
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
+ }
+ } else {
+ console.log(`[newsletter:test-outbox] to=${email.to} subject="${email.subject}"`);
+ console.log(record.text);
+ }
+
+ // Both modes append to the outbox (redacted) — the audit trail of every send.
+ await mkdir(path.dirname(OUTBOX), { recursive: true });
+ await appendFile(OUTBOX, JSON.stringify(record) + "\n", "utf8");
+}
+
+export function newToken(): string {
+ return randomBytes(32).toString("base64url");
+}
+
+export function normalizeEmail(raw: string): string {
+ return raw.trim().toLowerCase();
+}
+
+function footer(): string {
+ const addr = process.env.NEWSLETTER_MAILING_ADDRESS || "(mailing address pending)";
+ const from = process.env.NEWSLETTER_FROM_NAME || "GovArbitrage Digest";
+ return `—\n${from}\n${addr}`;
+}
+
+export function buildConfirmEmail(to: string, baseUrl: string, confirmToken: string): NewsletterEmail {
+ const confirmUrl = `${baseUrl}/api/newsletter/confirm?token=${confirmToken}`;
+ return {
+ to,
+ subject: "Confirm your GovArbitrage digest subscription",
+ text: [
+ "You (or someone using this address) asked to receive the GovArbitrage",
+ "Top-10 government-surplus deals digest, sent twice daily (6am & 5pm PT).",
+ "Confirm below and the next digest is yours.",
+ "",
+ `Confirm your subscription: ${confirmUrl}`,
+ "",
+ "If you didn't request this, ignore this email and you won't be subscribed.",
+ "",
+ footer(),
+ ].join("\n"),
+ };
+}
+
+export function buildUnsubscribeNoticeEmail(to: string): NewsletterEmail {
+ return {
+ to,
+ subject: "You've been unsubscribed from the GovArbitrage digest",
+ text: [
+ "You've been unsubscribed and will receive no further digests.",
+ "This was effective immediately — no login or confirmation was required.",
+ "",
+ footer(),
+ ].join("\n"),
+ };
+}
diff --git a/src/lib/password.ts b/src/lib/password.ts
new file mode 100644
index 0000000..7bb1bc7
--- /dev/null
+++ b/src/lib/password.ts
@@ -0,0 +1,21 @@
+import crypto from "node:crypto";
+
+// Password hashing with Node's built-in scrypt — no native deps. Format:
+// "scrypt$<saltHex>$<derivedKeyHex>". Node-runtime only (login route).
+
+const KEYLEN = 64;
+
+export function hashPassword(password: string): string {
+ const salt = crypto.randomBytes(16).toString("hex");
+ const dk = crypto.scryptSync(password, salt, KEYLEN).toString("hex");
+ return `scrypt$${salt}$${dk}`;
+}
+
+export function verifyPassword(password: string, stored: string): boolean {
+ const parts = stored.split("$");
+ if (parts.length !== 3 || parts[0] !== "scrypt") return false;
+ const [, salt, dkHex] = parts;
+ const expected = Buffer.from(dkHex, "hex");
+ const actual = crypto.scryptSync(password, salt, KEYLEN);
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
+}
diff --git a/src/lib/places.test.ts b/src/lib/places.test.ts
new file mode 100644
index 0000000..2158bd4
--- /dev/null
+++ b/src/lib/places.test.ts
@@ -0,0 +1,538 @@
+import { describe, expect, it } from "vitest";
+import {
+ isCommercial,
+ dedupeByContact,
+ normalizePhone,
+ normalizeHost,
+ relevanceScore,
+ type AgentResult,
+ type RawPlace,
+} from "./places";
+
+// isCommercial() is the finder's core value prop: precision-first
+// commercial-vs-residential classification (the LA 57→28 verified drop).
+// These offline tests lock that behavior in — zero live Google Places billing,
+// per project rails. The apply order under test (see places.ts):
+// 1. off-topic Google type → DROP
+// 2. strong commercial name OR allowlisted brand → KEEP (beats residential)
+// 3. residential/irrelevant name → DROP
+// 4. residential-flavored type → DROP
+// 5/6. no commercial evidence → DROP (precision > recall)
+
+function place(name: string, types: string[] = ["real_estate_agency"]): RawPlace {
+ return { id: "x", displayName: { text: name }, primaryType: types[0], types };
+}
+
+describe("isCommercial — KEEP on strong commercial name signals", () => {
+ it.each([
+ "Downtown Commercial Real Estate Brokers",
+ "Metro CRE Advisors",
+ "Industrial Property Group",
+ "Prime Office Leasing",
+ "Retail & Industrial Brokerage",
+ "Apex Investment Sales",
+ "Tenant Representation Partners",
+ "Net Lease Advisors",
+ "Summit Capital Markets",
+ "Bay Area Multifamily Group",
+ "Harbor Income Properties",
+ ])("keeps %s", (name) => {
+ expect(isCommercial(place(name))).toBe(true);
+ });
+});
+
+describe("isCommercial — KEEP on allowlisted national brokerage brands", () => {
+ it.each([
+ "CBRE",
+ "JLL Los Angeles",
+ "Cushman & Wakefield",
+ "Colliers International",
+ "Marcus & Millichap",
+ "Lee & Associates",
+ "Newmark Group",
+ "Avison Young",
+ "Hughes Marino",
+ "NAI Capital",
+ ])("keeps brand %s", (name) => {
+ expect(isCommercial(place(name))).toBe(true);
+ });
+
+ it("is case-insensitive on brand match", () => {
+ expect(isCommercial(place("cbre commercial"))).toBe(true);
+ expect(isCommercial(place("MARCUS AND MILLICHAP"))).toBe(true);
+ });
+
+ it("word-boundary matches brands — no substring over-match", () => {
+ // A short brand like "voit" must not fire inside an unrelated word: a
+ // residential "Savoit Home Realty" contains the substring "voit" but is
+ // NOT the Voit brand and must drop (residential). The real brand still keeps.
+ expect(isCommercial(place("Voit Real Estate Services"))).toBe(true);
+ expect(isCommercial(place("Savoit Home Realty"))).toBe(false);
+ expect(isCommercial(place("NAI Capital"))).toBe(true);
+ // "nai" must not fire inside "domain"/"Sinai" (no word boundary).
+ expect(isCommercial(place("Sinai Home Sales"))).toBe(false);
+ });
+
+ it("matches a multi-word brand across space/hyphen/dot separators", () => {
+ // Google Places returns these two-word brands with varied separators; the
+ // boundary match must not require a literal space between the words.
+ expect(isCommercial(place("Jones-Lang LaSalle"))).toBe(true);
+ expect(isCommercial(place("Avison-Young"))).toBe(true);
+ expect(isCommercial(place("Kidder.Mathews"))).toBe(true);
+ expect(isCommercial(place("Avison Young"))).toBe(true); // plain space still works
+ });
+});
+
+describe("isCommercial — commercial evidence WINS over a residential word", () => {
+ it("keeps a name carrying both commercial and residential signals", () => {
+ // "commercial" (KEEP, step 2) is evaluated before "homes" (DROP, step 3).
+ expect(isCommercial(place("Commercial & Home Sales Group"))).toBe(true);
+ });
+ it("keeps an allowlisted brand even next to a residential word", () => {
+ expect(isCommercial(place("CBRE Residential Division"))).toBe(true);
+ });
+});
+
+describe("isCommercial — DROP on residential / irrelevant names", () => {
+ it.each([
+ "John Smith, Realtor",
+ "Dream Homes Realty",
+ "Sunset Home Sales",
+ "First-Time Buyer Specialists",
+ "The Home Team",
+ "Bluewater Property Management",
+ "City Apartments Locators",
+ "Acme Mortgage & Home Loans",
+ "Luxury Condos of Malibu",
+ "Single Family Realty Team",
+ ])("drops %s", (name) => {
+ expect(isCommercial(place(name))).toBe(false);
+ });
+});
+
+describe("isCommercial — DROP residential firms rescued only by a generic token", () => {
+ // Regression: COMMERCIAL_RE carries vertical-AMBIGUOUS tokens (brokerage/
+ // advisors/leasing/investments) that appear in residential names too. The
+ // commercial keep runs before the residential drop, so these were wrongly
+ // KEPT as commercial brokers on the strength of the generic token alone.
+ it.each([
+ "Coldwell Banker Residential Brokerage", // "brokerage" only
+ "Smith Residential Advisors", // "advisors" only
+ "Apartment Homes Leasing", // "leasing" only, apartment locator
+ "Hometown Residential Brokerage",
+ "Family Home Realty Advisors",
+ "Condo Leasing Specialists",
+ ])("drops %s (no strong commercial token, no brand)", (name) => {
+ expect(isCommercial(place(name))).toBe(false);
+ });
+
+ it("still KEEPS a strong commercial token next to a residential word", () => {
+ expect(isCommercial(place("Commercial & Home Sales Group"))).toBe(true);
+ expect(isCommercial(place("Office & Residential Brokerage"))).toBe(true); // "office" strong
+ expect(isCommercial(place("Multifamily Apartment Advisors"))).toBe(true); // "multifamily" strong
+ });
+
+ it("still KEEPS an allowlisted brand next to a residential word", () => {
+ expect(isCommercial(place("CBRE Residential"))).toBe(true);
+ expect(isCommercial(place("Colliers Residential Brokerage"))).toBe(true);
+ });
+});
+
+describe("isCommercial — DROP professional-practice offices (kept only by 'office' token)", () => {
+ // "office" is a CRE asset-class token in COMMERCIAL_RE, so "Law Office of Smith"
+ // was wrongly kept as a commercial broker. The "office OF <person>" structure
+ // marks a practice; legit CRE "Office Building"/"Office Leasing" names have no "of".
+ it.each([
+ "Law Office of Smith & Associates",
+ "Dental Office of Dr. Lee",
+ "Medical Office of Dr. Patel",
+ "Chiropractic Office of Dr. Kim",
+ "Offices of Dr. Jane Doe",
+ "Office of Attorney Robert Vance",
+ ])("drops %s", (name) => {
+ expect(isCommercial(place(name))).toBe(false);
+ });
+
+ it("does NOT over-drop legit commercial-RE office names (no 'of' structure)", () => {
+ expect(isCommercial(place("Downtown Office Leasing Advisors"))).toBe(true);
+ expect(isCommercial(place("Medical Office Building Advisors"))).toBe(true);
+ expect(isCommercial(place("Suburban Office Properties Group"))).toBe(true);
+ expect(isCommercial(place("Office & Industrial Brokerage"))).toBe(true);
+ });
+
+ it("does NOT mis-fire on 'Dr'-/'attorney'-prefixed surnames after 'Office of'", () => {
+ // The "of dr" branch must require a boundary — "Drummond"/"Draper" are not "Dr.".
+ expect(isCommercial(place("Office of Drummond Commercial Partners"))).toBe(true);
+ expect(isCommercial(place("Office of Draper Industrial"))).toBe(true);
+ // A real "Dr." / "Attorney" practice still drops.
+ expect(isCommercial(place("Office of Dr. Chen Dental"))).toBe(false);
+ expect(isCommercial(place("Office of Attorney Robert Vance"))).toBe(false);
+ });
+});
+
+describe("isCommercial — DROP on generic / no-evidence real-estate results", () => {
+ it("drops a plain real_estate_agency with no commercial signal", () => {
+ // Precision-first: Google's real_estate_agency type can't confirm commercial.
+ expect(isCommercial(place("Smith Real Estate"))).toBe(false);
+ expect(isCommercial(place("Downtown Realty"))).toBe(false);
+ });
+ it("drops a non-real-estate result with no signal either way", () => {
+ expect(isCommercial(place("Bob's Consulting", ["consultant"]))).toBe(false);
+ });
+});
+
+describe("isCommercial — DROP on off-topic Google place types (regardless of name)", () => {
+ it.each([
+ ["The Maimon Group", "travel_agency"],
+ ["Cozy Stays", "lodging"],
+ ["Shield Insurance", "insurance_agency"],
+ ["Two Guys Moving", "moving_company"],
+ ["SecureBox Self Storage", "self_storage"],
+ ["Mario's Restaurant", "restaurant"],
+ ])("drops %s tagged %s", (name, type) => {
+ expect(isCommercial(place(name, [type]))).toBe(false);
+ });
+
+ it("off-topic type beats an otherwise-commercial name", () => {
+ // A travel_agency mis-tag on a 'commercial'-named place is still dropped.
+ expect(isCommercial(place("Commercial Getaways", ["travel_agency"]))).toBe(false);
+ });
+
+ // Regression: Google place types are underscore-joined ("furniture_store"), and
+ // "_" is a JS word char, so the old \bstore\b boundary NEVER fired inside them —
+ // every compound retail/mall type with a commercial-sounding name leaked through
+ // (an "Office Furniture Warehouse" tagged furniture_store was kept as a broker).
+ // Now matched by exact type token + a "*_store" suffix rule.
+ it.each([
+ ["Office Furniture Warehouse", "furniture_store"],
+ ["Commercial Hardware Supply", "hardware_store"],
+ ["Downtown Office Depot", "office_supply_store"],
+ ["Retail Plaza Shopping Mall", "shopping_mall"],
+ ["Prime Electronics Outlet", "electronics_store"],
+ ["Industrial Home Improvement Center", "home_improvement_store"],
+ ["Commercial Auto Group", "car_dealer"],
+ ["Office Legal Advisors", "lawyer"],
+ ])("drops %s (compound off-topic type %s) despite a commercial-sounding name", (name, type) => {
+ expect(isCommercial(place(name, [type]))).toBe(false);
+ });
+
+ it("catches an off-topic type buried in the secondary types array", () => {
+ // primaryType is real_estate_agency but a furniture_store type is also present.
+ expect(
+ isCommercial({
+ id: "x",
+ displayName: { text: "Commercial Furnishings & Realty" },
+ primaryType: "real_estate_agency",
+ types: ["real_estate_agency", "furniture_store"],
+ }),
+ ).toBe(false);
+ });
+
+ it("does NOT over-drop a legit commercial real_estate_agency (no *_store false hit)", () => {
+ expect(isCommercial(place("Commercial Real Estate Brokers", ["real_estate_agency"]))).toBe(true);
+ });
+});
+
+// Regression: the "broker" and "finance" verticals overlap, so a Places text
+// search for "commercial real estate broker" surfaces WEALTH / FINANCIAL-
+// ADVISORY / securities firms. Their names carry finance-ambiguous tokens
+// ("investments", "advisors", "capital markets") that ALSO satisfy the generic
+// COMMERCIAL_RE keep — so before the fix, "Sterling Financial Advisors" and
+// "Vanguard Investment Advisors" were WRONGLY kept as CRE brokers. Now a
+// financial-advisory name with NO real-estate anchor is dropped, while a legit
+// CRE broker (which carries an RE anchor or an allowlisted brand) is preserved.
+describe("isCommercial — DROP on financial-advisory / wealth firms with no RE anchor", () => {
+ it.each([
+ "Morgan Stanley Wealth Investments",
+ "Sterling Financial Advisors",
+ "Vanguard Investment Advisors",
+ "Edward Jones Investments",
+ "Pinnacle Wealth Advisors",
+ "Fidelity Asset Management",
+ "Prudential Insurance Advisors",
+ "Cornerstone Wealth Management",
+ "Cambridge Investment Management",
+ "Heritage Retirement Advisors",
+ ])("drops finance firm %s (no real-estate anchor)", (name) => {
+ expect(isCommercial(place(name))).toBe(false);
+ });
+
+ it.each([
+ // A real-estate anchor rescues a finance-ambiguous name — these are real CRE.
+ "Net Lease Advisors", // "net lease" + "leasing" anchor
+ "Metro CRE Advisors", // "CRE" anchor
+ "Apex Investment Sales", // "investment sales" is the CRE-specific phrase
+ "Harbor Income Properties", // "properties" anchor
+ "Commercial Real Estate Investment Advisors", // "real estate" anchor
+ "Summit Capital Markets", // capital markets alone isn't a finance-drop token
+ ])("keeps CRE broker %s despite a finance-ambiguous word", (name) => {
+ expect(isCommercial(place(name))).toBe(true);
+ });
+
+ it("keeps an allowlisted CRE brand even when the name carries a finance word", () => {
+ // Brand allowlist overrides the financial-advisory drop.
+ expect(isCommercial(place("CBRE Investment Management"))).toBe(true);
+ expect(isCommercial(place("JLL Capital Markets"))).toBe(true);
+ });
+
+ it("still keeps a plainly-commercial broker with no finance word at all", () => {
+ // Guard against the finance gate over-reaching into ordinary CRE names.
+ expect(isCommercial(place("Downtown Commercial Leasing"))).toBe(true);
+ });
+});
+
+// CRE-adjacent SERVICE / trade false-positive gate. A Places "commercial real
+// estate broker" text search surfaces firms that WORK on commercial property but
+// are NOT buyer/leasing brokers — appraisers, title/escrow, construction, mortgage
+// lenders, inspectors, property/facilities managers. Their names carry the same
+// "commercial"/"property" tokens that satisfy COMMERCIAL_RE, so before the fix
+// "Commercial Property Appraisers" and "Sunbelt Commercial Construction" were
+// WRONGLY kept as brokers. Now a CRE-service name with NO explicit broker anchor
+// is dropped, while a real brokerage (broker anchor or allowlisted brand) — even
+// one with a valuation / property-management arm — is preserved.
+describe("isCommercial — DROP on CRE-adjacent service/trade firms with no broker anchor", () => {
+ it.each([
+ "Commercial Property Appraisers Inc",
+ "Titan Commercial Title & Escrow",
+ "Sunbelt Commercial Construction",
+ "Meridian Commercial Property Management",
+ "Apex Commercial Mortgage Capital",
+ "National Commercial Property Inspections",
+ "Green Commercial Environmental Services",
+ "Premier Commercial Interior Design Group",
+ "Statewide Commercial Escrow Services",
+ "Metro Commercial Facilities Management",
+ "Cornerstone Commercial Appraisal & Valuation",
+ "Summit Commercial General Contractors",
+ ])("drops CRE-service firm %s (no broker anchor)", (name) => {
+ expect(isCommercial(place(name))).toBe(false);
+ });
+
+ it.each([
+ // A broker anchor rescues a name that also mentions a service word.
+ "Titan Commercial Real Estate Brokerage", // "brokerage" + "real estate"
+ "Landmark Commercial Realty & Property Management", // "commercial realty" broker + PM arm
+ "Apex Commercial Real Estate & Construction Advisors", // "real estate" anchor
+ "Downtown Commercial Leasing", // "leasing" anchor, no service word
+ "Net Lease Advisors", // pure broker, unaffected
+ "Harbor Investment Sales", // "investment sales" anchor
+ ])("keeps CRE broker %s despite (or without) a service word", (name) => {
+ expect(isCommercial(place(name))).toBe(true);
+ });
+
+ it("keeps an allowlisted brand's valuation / management arm", () => {
+ // Brand allowlist overrides the CRE-service drop.
+ expect(isCommercial(place("Cushman & Wakefield Valuation Services"))).toBe(true);
+ expect(isCommercial(place("CBRE Property Management"))).toBe(true);
+ expect(isCommercial(place("Colliers Appraisal Group"))).toBe(true);
+ });
+
+ it("still keeps a plainly-commercial broker with no service word at all", () => {
+ // Guard against the service gate over-reaching into ordinary CRE names.
+ expect(isCommercial(place("Commercial Realty Advisors"))).toBe(true);
+ expect(isCommercial(place("Downtown Commercial Real Estate Brokers"))).toBe(true);
+ });
+});
+
+describe("isCommercial — DROP on residential-flavored place types", () => {
+ it.each(["apartment_complex", "home_goods_store"])("drops type %s with no commercial name", (type) => {
+ expect(isCommercial(place("Parkview", [type]))).toBe(false);
+ });
+ it("commercial name still wins over a residential-flavored type", () => {
+ expect(isCommercial(place("Commercial Leasing at Parkview", ["apartment_complex"]))).toBe(true);
+ });
+});
+
+describe("isCommercial — edge cases", () => {
+ it("drops a place with no name", () => {
+ expect(isCommercial({ id: "x", displayName: { text: "" }, types: ["real_estate_agency"] })).toBe(false);
+ });
+ it("drops a place with no types and no commercial name", () => {
+ expect(isCommercial({ id: "x", displayName: { text: "Generic Agency" } })).toBe(false);
+ });
+});
+
+// --- Contact-based dedupe -------------------------------------------------
+// dedupeByContact() collapses the same office surfaced under multiple lenses as
+// different place ids. Phone is authoritative; website only merges when phones
+// don't contradict (national brands share one domain across many branches).
+// Offline + deterministic — zero live Google Places billing.
+
+describe("normalizePhone", () => {
+ it.each([
+ ["(213) 555-0123", "2135550123"],
+ ["213-555-0123", "2135550123"],
+ ["+1 213 555 0123", "2135550123"],
+ ["1 (213) 555-0123", "2135550123"],
+ ])("normalizes %s → %s", (input, expected) => {
+ expect(normalizePhone(input)).toBe(expected);
+ });
+ it("returns null for null / partial / non-10-digit numbers", () => {
+ expect(normalizePhone(null)).toBeNull();
+ expect(normalizePhone("555-0123")).toBeNull(); // 7 digits — not keyable
+ expect(normalizePhone("")).toBeNull();
+ });
+});
+
+describe("normalizeHost", () => {
+ it.each([
+ ["https://www.cbre.com/office/la", "cbre.com"],
+ ["http://CBRE.com", "cbre.com"],
+ ["https://la.cbre.com", "la.cbre.com"], // subdomains stay distinct (precise)
+ ])("normalizes %s → %s", (input, expected) => {
+ expect(normalizeHost(input)).toBe(expected);
+ });
+ it("returns null for null / unparseable", () => {
+ expect(normalizeHost(null)).toBeNull();
+ expect(normalizeHost("not a url")).toBeNull();
+ });
+});
+
+function agent(over: Partial<AgentResult> & { id: string; name: string }): AgentResult {
+ return {
+ address: null,
+ rating: null,
+ reviews: 0,
+ phone: null,
+ website: null,
+ mapsUrl: null,
+ primaryType: null,
+ kinds: ["buyer"],
+ ...over,
+ };
+}
+
+describe("dedupeByContact", () => {
+ it("collapses the same office surfaced under two lenses (same phone)", () => {
+ // The motivating case: "CBRE" + "CBRE Investment Sales", same office/phone,
+ // two different place ids from two different query lenses.
+ const out = dedupeByContact([
+ agent({ id: "1", name: "CBRE", phone: "(213) 555-0100", reviews: 40, rating: 4.5, kinds: ["buyer"] }),
+ agent({
+ id: "2",
+ name: "CBRE Investment Sales",
+ phone: "213-555-0100",
+ reviews: 3,
+ rating: 4.9,
+ kinds: ["leasing"],
+ }),
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0].id).toBe("1"); // first-seen kept canonical
+ expect(out[0].kinds.sort()).toEqual(["buyer", "leasing"]); // kinds unioned
+ // Richer rating/review pair adopted from the MORE-rated listing (id 1).
+ expect(out[0].reviews).toBe(40);
+ expect(out[0].rating).toBe(4.5);
+ });
+
+ it("keeps two DIFFERENT branch offices of one brand (same domain, different phones)", () => {
+ const out = dedupeByContact([
+ agent({ id: "1", name: "CBRE Downtown", phone: "213-555-0100", website: "https://www.cbre.com" }),
+ agent({ id: "2", name: "CBRE West LA", phone: "310-555-0200", website: "https://www.cbre.com" }),
+ ]);
+ expect(out).toHaveLength(2); // distinct phones ⇒ distinct offices, never merged
+ });
+
+ it("merges on website only when phones don't contradict (one side phone-less)", () => {
+ const out = dedupeByContact([
+ agent({ id: "1", name: "JLL", phone: "213-555-0300", website: "https://jll.com" }),
+ agent({ id: "2", name: "JLL Capital Markets", phone: null, website: "https://jll.com" }),
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0].id).toBe("1");
+ });
+
+ it("enriches the canonical's missing contact fields from the duplicate", () => {
+ const out = dedupeByContact([
+ agent({ id: "1", name: "Colliers", phone: "213-555-0400", website: null, address: null }),
+ agent({
+ id: "2",
+ name: "Colliers Intl",
+ phone: "213-555-0400",
+ website: "https://colliers.com",
+ address: "1 Main St",
+ }),
+ ]);
+ expect(out).toHaveLength(1);
+ expect(out[0].website).toBe("https://colliers.com"); // filled from dup
+ expect(out[0].address).toBe("1 Main St"); // filled from dup
+ });
+
+ it("leaves genuinely distinct agencies untouched (no shared phone or host)", () => {
+ const out = dedupeByContact([
+ agent({ id: "1", name: "Newmark", phone: "213-555-0500", website: "https://nmrk.com" }),
+ agent({ id: "2", name: "Kidder Mathews", phone: "206-555-0600", website: "https://kidder.com" }),
+ ]);
+ expect(out).toHaveLength(2);
+ });
+
+ it("does not merge two phone-distinct entries that lack websites", () => {
+ const out = dedupeByContact([
+ agent({ id: "1", name: "Lee & Associates", phone: "213-555-0700" }),
+ agent({ id: "2", name: "Avison Young", phone: "213-555-0800" }),
+ ]);
+ expect(out).toHaveLength(2);
+ });
+});
+
+// relevanceScore() weights the RANKED list so a stronger CRE-broker match
+// (national brand / strong commercial name / surfaced by both lenses) surfaces
+// ahead of a weakly-commercial one at comparable rating-weight — without a
+// 0-review brand leapfrogging a genuinely reviewed office. Offline, no billing.
+describe("relevanceScore", () => {
+ it("returns the 1.0 baseline for a plain commercial listing", () => {
+ // No allowlisted brand, no strong COMMERCIAL_RE hit in the name, one lens.
+ expect(relevanceScore(agent({ id: "x", name: "Downtown Advisory" }))).toBeCloseTo(1.0);
+ });
+
+ it("adds weight for an allowlisted national brokerage brand", () => {
+ const s = relevanceScore(agent({ id: "x", name: "CBRE", kinds: ["buyer"] }));
+ expect(s).toBeGreaterThan(1.0);
+ // CBRE hits the brand bonus (+0.3) AND "commercial"? no — name is bare "CBRE"
+ // so only the brand bonus applies.
+ expect(s).toBeCloseTo(1.3);
+ });
+
+ it("adds weight for a strong commercial name signal", () => {
+ // "Industrial Property Group" hits COMMERCIAL_RE but is not an allowlist brand.
+ expect(relevanceScore(agent({ id: "x", name: "Industrial Property Group" }))).toBeCloseTo(1.2);
+ });
+
+ it("adds weight when surfaced by BOTH the buyer and leasing lenses", () => {
+ const both = relevanceScore(agent({ id: "x", name: "Anon Realty", kinds: ["buyer", "leasing"] }));
+ const one = relevanceScore(agent({ id: "x", name: "Anon Realty", kinds: ["buyer"] }));
+ expect(both - one).toBeCloseTo(0.1);
+ });
+
+ it("stacks signals (brand + commercial name + both lenses) up to the bound", () => {
+ const s = relevanceScore(
+ agent({ id: "x", name: "CBRE Commercial", kinds: ["buyer", "leasing"] }),
+ );
+ // +0.3 brand +0.2 commercial-name +0.1 both-lenses = 1.6 ceiling.
+ expect(s).toBeCloseTo(1.6);
+ });
+});
+
+// The ranking must PROMOTE a stronger commercial match at comparable
+// rating-weight, but a zero-review brand must NOT jump a genuinely reviewed
+// office. dedupeByContact is a pass-through here (distinct contacts), so we can
+// assert ordering behavior on the same sort comparator via a tiny local sort
+// mirroring searchCommercialAgents (kept in sync with places.ts).
+describe("relevance-weighted ranking behavior", () => {
+ const rankWeight = (r: AgentResult) =>
+ (r.rating ?? 0) * Math.log((r.reviews ?? 0) + 1) * relevanceScore(r);
+
+ it("promotes a stronger CRE match over a weakly-commercial one at equal rating/reviews", () => {
+ const brand = agent({ id: "1", name: "CBRE", rating: 4.5, reviews: 30, kinds: ["buyer", "leasing"] });
+ const weak = agent({ id: "2", name: "Anon Advisory", rating: 4.5, reviews: 30, kinds: ["buyer"] });
+ expect(rankWeight(brand)).toBeGreaterThan(rankWeight(weak));
+ });
+
+ it("does NOT let a zero-review brand leapfrog a well-reviewed office", () => {
+ const brandNoReviews = agent({ id: "1", name: "CBRE", rating: 5, reviews: 0, kinds: ["buyer", "leasing"] });
+ const reviewedOffice = agent({ id: "2", name: "Metro Advisory", rating: 4.2, reviews: 120, kinds: ["buyer"] });
+ // rating × log(1) = 0 for the brand regardless of relevance multiplier.
+ expect(rankWeight(brandNoReviews)).toBe(0);
+ expect(rankWeight(reviewedOffice)).toBeGreaterThan(rankWeight(brandNoReviews));
+ });
+});
diff --git a/src/lib/places.ts b/src/lib/places.ts
new file mode 100644
index 0000000..9f339b9
--- /dev/null
+++ b/src/lib/places.ts
@@ -0,0 +1,634 @@
+// Google Places API (New) — Text Search for COMMERCIAL real-estate professionals
+// near a government-surplus property: buyer / acquisition brokers + leasing agents.
+//
+// Commercial-only by construction: the text queries target commercial CRE, and a
+// light residential filter drops obvious home-sale / apartment-locator results
+// ("just commercial for this build"). Server-side ONLY — the API key never reaches
+// the client. Live-query with `no-store`; we don't persist Google place data beyond
+// the response (Places ToS: no long-term caching of place fields except the id).
+
+export type AgentKind = "buyer" | "leasing";
+
+export interface AgentResult {
+ id: string;
+ name: string;
+ address: string | null;
+ rating: number | null;
+ reviews: number;
+ phone: string | null;
+ website: string | null;
+ mapsUrl: string | null;
+ primaryType: string | null;
+ kinds: AgentKind[]; // which query lens(es) surfaced it
+}
+
+export interface CommercialAgentSearch {
+ city?: string;
+ state?: string;
+ zip?: string;
+ kinds: AgentKind[];
+}
+
+export interface CommercialAgentResponse {
+ location: string;
+ kinds: AgentKind[];
+ results: AgentResult[];
+ queries: number; // billed Places Text Search calls (for cost surfacing)
+}
+
+const ENDPOINT = "https://places.googleapis.com/v1/places:searchText";
+const FIELD_MASK = [
+ "places.id",
+ "places.displayName",
+ "places.formattedAddress",
+ "places.rating",
+ "places.userRatingCount",
+ "places.nationalPhoneNumber",
+ "places.websiteUri",
+ "places.googleMapsUri",
+ "places.businessStatus",
+ "places.primaryTypeDisplayName",
+ "places.types",
+ "places.primaryType",
+].join(",");
+
+// Curated query "lenses" per kind — a small set (≤3) of complementary CRE
+// angles that widen coverage without ballooning cost. Each lens is one billed
+// Places Text Search call; results are unioned across lenses by place id.
+const KIND_LENSES: Record<AgentKind, string[]> = {
+ buyer: [
+ "commercial real estate broker",
+ "commercial real estate investment sales broker",
+ "tenant representation broker",
+ ],
+ leasing: [
+ "commercial real estate leasing agent",
+ "office space leasing broker",
+ "retail industrial leasing broker",
+ ],
+};
+
+// Positive COMMERCIAL name evidence — the primary "keep" gate. A real-estate
+// result is only kept if it carries one of these strong commercial signals (or
+// an allowlisted brokerage brand, below). "office"/"retail"/"industrial" are
+// asset-class words that reliably read commercial in a broker name; "leasing"
+// stays because our query lenses are commercial-leasing lenses.
+const COMMERCIAL_RE =
+ /\b(commercial|CRE|industrial|retail|office|investment\s+sales|investments?|tenant\s+rep(?:resentation)?|net\s+lease|leasing|brokerage|advisors?|capital\s+markets|properties\s+group|commercial\s+properties|multifamily|commercial\s+group|income\s+propert(?:y|ies))\b/i;
+
+// STRONG (vertical-UNAMBIGUOUS) commercial tokens — the subset of COMMERCIAL_RE
+// that reads commercial-only. It deliberately EXCLUDES the vertical-ambiguous
+// generics that appear just as often in residential firm names — "brokerage",
+// "advisors", "leasing", "investments" — because a residential firm is a
+// "brokerage" with "advisors" doing "leasing" too. Used by the residential gate
+// below so an explicit residential name isn't rescued by a generic token alone.
+const STRONG_COMMERCIAL_RE =
+ /\b(commercial|CRE|industrial|retail|office|investment\s+sales|tenant\s+rep(?:resentation)?|net\s+lease|capital\s+markets|properties\s+group|commercial\s+properties|multifamily|commercial\s+group|income\s+propert(?:y|ies))\b/i;
+
+// Known national / regional COMMERCIAL brokerage brands. Case-insensitive
+// substring match — presence of any of these keeps the result outright, on par
+// with a strong commercial name signal (and ahead of the residential drop).
+const COMMERCIAL_BRANDS: string[] = [
+ "cbre",
+ "jll",
+ "jones lang",
+ "cushman",
+ "wakefield",
+ "colliers",
+ "newmark",
+ "marcus & millichap",
+ "marcus and millichap",
+ "kidder mathews",
+ "lee & associates",
+ "lee and associates",
+ "avison young",
+ "savills",
+ "matthews real estate",
+ "stan johnson",
+ "northmarq",
+ "berkadia",
+ "institutional property advisors",
+ "voit",
+ "daum",
+ "nai ",
+ "nai capital",
+ "cresa",
+ "transwestern",
+ "stream realty",
+ "hughes marino",
+];
+
+// RESIDENTIAL / irrelevant name signals — drop UNLESS a commercial signal or an
+// allowlisted brand overrides (apply order enforced in isCommercial). Kept
+// word-boundary aware so "homes" hits "Earnest Homes" but the standalone-noun
+// senses don't over-match. Generic "realty"/"real estate" with no commercial
+// evidence is handled by the positive-gate default-drop, not here.
+const RESIDENTIAL_RE =
+ /\b(residential|homes?|home\s+sales?|for\s+sale|realtor|property\s+m(?:anage|gmt)ment|apartments?|condos?|single\s+family|first[-\s]?time|home\s+team|realty\s+team|real\s+estate\s+team|mortgage|home\s+loans?)\b/i;
+
+// Places `types` signals. `real_estate_agency` is the generic real-estate type
+// (does NOT distinguish commercial vs residential — name text decides).
+const REAL_ESTATE_TYPES = new Set(["real_estate_agency"]);
+
+// Off-vertical / off-topic Google place types — drop on match regardless of
+// name. "The Maimon Group" came back as travel_agency; storage/lodging/moving/
+// insurance are common mis-tags that are never a commercial broker.
+//
+// Matched by EXACT type-token equality (not a \b-regex): Google's place types
+// are underscore-joined tokens (e.g. "furniture_store"), and "_" is a JS word
+// character, so a \bstore\b boundary never fires inside "furniture_store" —
+// it silently let every "*_store" / "shopping_mall" / etc. type through. Any
+// type ending in "_store" is also treated as off-topic (Places has dozens of
+// retail *_store subtypes: hardware_store, electronics_store, furniture_store…).
+const OFF_TOPIC_TYPES = new Set([
+ "travel_agency",
+ "lodging",
+ "insurance_agency",
+ "moving_company",
+ "storage",
+ "self_storage",
+ "restaurant",
+ "food",
+ "store",
+ "school",
+ "hospital",
+ "gym",
+ "shopping_mall",
+ "finance", // banks/lenders mis-tagged onto RE queries — not a broker
+ "lawyer",
+ "accounting",
+ "car_dealer",
+ "car_rental",
+ "general_contractor",
+ "home_improvement_store",
+]);
+
+function isOffTopicType(t: string): boolean {
+ const tt = t.toLowerCase();
+ return OFF_TOPIC_TYPES.has(tt) || tt.endsWith("_store");
+}
+
+// Residential-flavored place types — exact-token match (same underscore-boundary
+// reason as above). Complements the off-topic blocklist for apartment/home mis-tags.
+const RESIDENTIAL_TYPES = new Set(["apartment_complex", "home_goods_store"]);
+
+// FINANCIAL-ADVISORY / wealth / securities name signals — the "broker" and
+// "finance" verticals overlap, so a Places text search for "commercial real
+// estate broker" reliably surfaces wealth-management, financial-advisory and
+// securities firms. Google's `finance` type-gate only catches the ones Google
+// explicitly tagged finance; many advisory firms come back tagged
+// `establishment`/`point_of_interest` and slip past it. These names carry the
+// finance-ambiguous tokens ("investments", "advisors", "capital markets") that
+// ALSO appear in legit CRE names, so a firm matching this is dropped UNLESS it
+// also carries an unambiguous real-estate anchor (RE_ANCHOR_RE, below).
+// Note: "investment sales" is a CRE-specific phrase and is EXCLUDED here (it's a
+// real-estate anchor, below) — only bare "investment(s)" / "investment advisors"
+// (with no RE anchor) reads as a wealth firm. The negative lookahead on
+// "investment" avoids matching "investment sales".
+const FINANCIAL_ADVISORY_RE =
+ /\b(wealth|financial\s+advisors?|financial\s+planning|financial\s+services|financial\s+group|securities|asset\s+management|wealth\s+management|investment\s+management|investment\s+advisors?|investments?(?!\s+sales)|private\s+equity|hedge\s+fund|insurance|retirement|portfolio\s+management|mutual\s+funds?|stockbrokers?|401k?)\b/i;
+
+// Unambiguous REAL-ESTATE anchor words. Presence of one proves a
+// finance-ambiguous name (e.g. "Investment Sales Advisors") is genuinely a
+// commercial-RE broker and not a wealth firm, so it survives the financial-
+// advisory drop. "real estate" / "realty" / "property/properties" / "leasing"
+// are the reliable RE anchors; "CRE" and "commercial real estate" too.
+const RE_ANCHOR_RE =
+ /\b(real\s+estate|realty|CRE|propert(?:y|ies)|leasing|net\s+lease|tenant\s+rep(?:resentation)?|multifamily|land\s+brokerage|investment\s+sales|commercial)\b/i;
+
+// CRE-ADJACENT SERVICE / TRADE name signals — firms that WORK on commercial
+// property but are NOT buyer/leasing brokers: appraisal/valuation, title &
+// escrow, construction & general contracting, mortgage lending / loan servicing,
+// property inspection, environmental, interior design, architecture/engineering,
+// surveying, facilities & property management, and the trades (roofing/HVAC/…).
+// A Places "commercial real estate broker" text search surfaces these because
+// their names carry the same "commercial"/"property" asset-class tokens that
+// satisfy COMMERCIAL_RE (e.g. "Commercial Property Appraisers", "Sunbelt
+// Commercial Construction", "Apex Commercial Mortgage Capital"), so they'd
+// otherwise be kept as brokers and pollute the "brokers near this property"
+// answer. This is distinct from the FINANCIAL_ADVISORY drop (wealth/securities
+// firms) and from the off-topic Google-`types` drop (retail/mall mis-tags): here
+// the NAME itself reads commercial-RE, but the firm is a CRE trade/service, not a
+// brokerage. Dropped UNLESS the name ALSO carries an explicit broker anchor
+// (BROKER_ANCHOR_RE) or an allowlisted brokerage brand — a full-service
+// brokerage that happens to run a valuation / property-management arm survives.
+const CRE_SERVICE_RE =
+ /\b(appraisals?|appraisers?|valuations?|title(?:\s+(?:company|services|insurance|&\s+escrow))?|escrow|construction|general\s+contract(?:or|ing)|contractors?|mortgage(?!\s+broker)|lending|lenders?|loan\s+servicing|inspections?|inspectors?|environmental|interior\s+design|architects?|architecture|engineering|surveyors?|surveying|janitorial|cleaning|landscaping|roofing|hvac|plumbing|restoration|property\s+tax|facilit(?:y|ies)\s+m(?:anage|gmt)ment|property\s+m(?:anage|gmt)ment)\b/i;
+
+// Explicit BROKER anchor — proves the firm actually does brokerage even if its
+// name also mentions a service word (e.g. a "Realty & Property Management" firm
+// or a "Real Estate & Construction Advisors" brokerage). Presence of one of
+// these overrides the CRE-service drop and keeps the listing.
+const BROKER_ANCHOR_RE =
+ /\b(brokers?|brokerage|realty|real\s+estate|leasing|tenant\s+rep(?:resentation)?|investment\s+sales|net\s+lease|realtors?)\b/i;
+
+// Word-boundary match each brand rather than a bare substring: a plain
+// `name.includes("voit")` also fires inside unrelated words ("Savoit Home
+// Realty" → wrongly treated as the Voit brand, then kept as commercial), and
+// short brands (voit/daum/nai) are the ones most prone to it. A `\bBRAND\b`
+// test keeps "Voit Real Estate" while rejecting "Savoit…". Brand strings are
+// regex-escaped and trimmed (the legacy "nai " trailing-space guard is now
+// redundant — `\bnai\b` already won't fire inside "domain"/"Sinai").
+// Professional PRACTICE office — a law / dental / medical / chiropractic / … firm
+// named "<practice> Office of …" or "Office(s) of Dr./Attorney …". NOT a CRE
+// broker: "office" is a CRE asset-class token in COMMERCIAL_RE, so "Law Office of
+// Smith & Associates" was kept as a commercial broker. The reliable giveaway is
+// the "office OF <person/Dr./attorney>" structure — a practice names itself
+// "Office of Dr. Lee", whereas a CRE firm says "Medical Office BUILDING Advisors"
+// or "Office LEASING Group" (no "of" after "office"). Deliberately narrow (requires
+// the "of" preposition) so it does NOT touch legit CRE office names. An allowlisted
+// brand or explicit broker anchor overrides.
+const PROFESSIONAL_OFFICE_RE =
+ /\b(?:law|dental|medical|chiropractic|veterinary|optometry|accounting|dermatolog\w*|orthodont\w*|podiatr\w*|pediatric|psychiatr\w*|counsel(?:ing|ling))\s+offices?\s+of\b|\boffices?\s+of\s+(?:drs?\.?\b|attorney\b|the\s+law\b)/i;
+
+function isProfessionalPracticeOffice(name: string): boolean {
+ return (
+ PROFESSIONAL_OFFICE_RE.test(name) &&
+ !BROKER_ANCHOR_RE.test(name) &&
+ !hasCommercialBrand(name)
+ );
+}
+
+function hasCommercialBrand(name: string): boolean {
+ const n = name.toLowerCase();
+ return COMMERCIAL_BRANDS.some((b) => {
+ const esc = b.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ // A multi-word brand ("jones lang", "avison young") appears in real Google
+ // Places names with a space, hyphen, or dot separator ("Jones-Lang LaSalle",
+ // "Avison-Young"). Match any of those between words so the boundary fix
+ // doesn't trade the substring over-match bug for a separator-rigidity miss.
+ const pat = esc.replace(/ /g, "[\\s\\-.]");
+ return new RegExp(`\\b${pat}\\b`, "i").test(n);
+ });
+}
+
+// True when the name reads as a CRE-adjacent SERVICE / trade firm (appraisal,
+// title/escrow, construction, mortgage lending, inspection, property/facilities
+// management, …) that carries NO explicit broker anchor — i.e. a service
+// false-positive we must drop even though its "commercial"/"property" tokens
+// would otherwise satisfy COMMERCIAL_RE. An allowlisted brokerage brand always
+// overrides (a real brokerage's valuation / PM arm stays).
+function isCreServiceNoBroker(name: string): boolean {
+ return (
+ CRE_SERVICE_RE.test(name) &&
+ !BROKER_ANCHOR_RE.test(name) &&
+ !hasCommercialBrand(name)
+ );
+}
+
+// True when the name reads as a financial-advisory / wealth firm that lacks a
+// real-estate anchor — i.e. a finance false-positive we must drop even though a
+// bare finance-ambiguous token (investments/advisors/capital markets) would
+// otherwise satisfy COMMERCIAL_RE. An allowlisted CRE brand always overrides
+// (a real brokerage that happens to include "insurance" etc. in its name stays).
+function isFinancialAdvisoryNoRE(name: string): boolean {
+ return (
+ FINANCIAL_ADVISORY_RE.test(name) &&
+ !RE_ANCHOR_RE.test(name) &&
+ !hasCommercialBrand(name)
+ );
+}
+
+// True when the name carries an EXPLICIT residential signal (residential/homes/
+// apartments/realtor/…) and its only "commercial" evidence is a vertical-
+// AMBIGUOUS generic token (brokerage / advisors / leasing / investments) — i.e.
+// no STRONG (commercial-only) token and no allowlisted brand. This is the
+// residential mirror of the finance/service gates: without it, the positive
+// keep (COMMERCIAL_RE) short-circuits before the residential drop, so a plain
+// residential firm like "Coldwell Banker Residential Brokerage" or "Smith
+// Residential Advisors" is wrongly kept as a commercial broker on the strength
+// of "brokerage"/"advisors" alone. A strong commercial token ("Commercial &
+// Home Sales Group") or an allowlisted brand ("CBRE Residential") still wins.
+function isResidentialDespiteGeneric(name: string): boolean {
+ return (
+ RESIDENTIAL_RE.test(name) &&
+ !STRONG_COMMERCIAL_RE.test(name) &&
+ !hasCommercialBrand(name)
+ );
+}
+
+// --- Commercial-relevance ranking weight ----------------------------------
+// Every result in the ranked list has ALREADY passed isCommercial() (precision
+// gate), but they're not equally strong CRE-broker matches. A listing that is
+// an allowlisted national brokerage AND was surfaced by BOTH the buyer- and
+// leasing-lenses is a far more confident "commercial broker near this property"
+// answer than a lone generic result that only just cleared the commercial name
+// gate. The old ranking (pure rating × log(reviews)) ignored this entirely, so
+// a highly-rated but weakly-commercial listing outranked a two-lens national
+// brokerage with modest reviews.
+//
+// relevanceScore() returns a small multiplier (1.0 baseline → up to ~1.6) that
+// nudges stronger CRE matches upward. It's deliberately BOUNDED and multiplied
+// into the existing rating-weight so it re-orders near-comparable results and
+// breaks near-ties in favor of the better commercial match — WITHOUT letting a
+// zero-review brand leapfrog a genuinely well-reviewed office (a 0-review brand
+// still scores rating×log(1)=0 and stays below any reviewed listing). Pure +
+// deterministic; unit-tested offline with zero live Places billing.
+export function relevanceScore(r: AgentResult): number {
+ let s = 1;
+ // Allowlisted national/regional brokerage brand → the most confident signal.
+ if (hasCommercialBrand(r.name)) s += 0.3;
+ // Strong commercial name evidence (CRE/industrial/office/investment sales…).
+ if (COMMERCIAL_RE.test(r.name)) s += 0.2;
+ // Surfaced by BOTH the buyer- and leasing-lens → broader CRE relevance.
+ if (r.kinds.includes("buyer") && r.kinds.includes("leasing")) s += 0.1;
+ return s;
+}
+
+// --- Contact-based dedupe -------------------------------------------------
+// Google Places returns the SAME physical brokerage office under several of our
+// query lenses as DIFFERENT place ids (e.g. "CBRE" from the buyer lens and
+// "CBRE Investment Sales" from the investment-sales lens). Place-id union alone
+// can't collapse those, so we add a precision-first second pass:
+//
+// • PHONE is the authoritative per-office identity — one number rings one
+// desk. Same normalized phone ⇒ same office ⇒ merge.
+// • WEBSITE is a WEAKER signal: national brands share ONE domain across every
+// branch (all CBRE offices are cbre.com). So a website match only merges
+// when the phones DON'T contradict (at least one side is phone-less, or the
+// phones are equal). Two same-domain entries with two DIFFERENT phones are
+// two real branch offices and are kept apart.
+
+// Normalize a US phone to its 10 significant digits, or null if it isn't a
+// usable 10-digit number (precision-first: never key a merge on partial digits).
+export function normalizePhone(phone: string | null): string | null {
+ if (!phone) return null;
+ let d = phone.replace(/\D+/g, "");
+ if (d.length === 11 && d.startsWith("1")) d = d.slice(1);
+ return d.length === 10 ? d : null;
+}
+
+// Normalize a website to its lowercased host without a leading "www.", or null
+// if it doesn't parse. Full host (not just registrable domain) keeps it precise:
+// a duplicate of the same office carries the identical URL, so it still matches.
+export function normalizeHost(website: string | null): string | null {
+ if (!website) return null;
+ try {
+ const h = new URL(website).hostname.toLowerCase().replace(/^www\./, "");
+ return h || null;
+ } catch {
+ return null;
+ }
+}
+
+// Collapse near-identical listings that are the same office surfaced twice.
+// Keeps the first-seen record as canonical (preserving the ranked insertion
+// order), unions its `kinds`, adopts the richer rating/review pair, and fills
+// any missing contact fields from the duplicate. Pure + deterministic — no I/O,
+// so it's unit-tested offline with zero live Places billing.
+export function dedupeByContact(results: AgentResult[]): AgentResult[] {
+ const canon: AgentResult[] = [];
+ const byPhone = new Map<string, AgentResult>();
+ const byHost = new Map<string, AgentResult>();
+
+ const merge = (into: AgentResult, dup: AgentResult) => {
+ for (const k of dup.kinds) if (!into.kinds.includes(k)) into.kinds.push(k);
+ // Adopt the more-rated listing's rating/review pair (don't double-count).
+ if (dup.reviews > into.reviews) {
+ into.rating = dup.rating;
+ into.reviews = dup.reviews;
+ }
+ into.address ??= dup.address;
+ into.phone ??= dup.phone;
+ into.website ??= dup.website;
+ into.primaryType ??= dup.primaryType;
+ into.mapsUrl ??= dup.mapsUrl;
+ // Register any contact key the canonical only just adopted from the dup.
+ const p = normalizePhone(into.phone);
+ if (p) byPhone.set(p, into);
+ const h = normalizeHost(into.website);
+ if (h && !byHost.has(h)) byHost.set(h, into);
+ };
+
+ for (const r of results) {
+ const p = normalizePhone(r.phone);
+ const h = normalizeHost(r.website);
+
+ // 1. Same phone ⇒ same office (authoritative).
+ if (p && byPhone.has(p)) {
+ merge(byPhone.get(p)!, r);
+ continue;
+ }
+ // 2. Same website ⇒ merge ONLY when phones don't prove they're different
+ // offices (national brands share one domain across many branches).
+ if (h && byHost.has(h)) {
+ const c = byHost.get(h)!;
+ const cp = normalizePhone(c.phone);
+ if (!p || !cp || p === cp) {
+ merge(c, r);
+ continue;
+ }
+ }
+ // New canonical office.
+ canon.push(r);
+ if (p) byPhone.set(p, r);
+ if (h && !byHost.has(h)) byHost.set(h, r);
+ }
+ return canon;
+}
+
+export interface RawPlace {
+ id?: string;
+ displayName?: { text?: string };
+ formattedAddress?: string;
+ rating?: number;
+ userRatingCount?: number;
+ nationalPhoneNumber?: string;
+ websiteUri?: string;
+ googleMapsUri?: string;
+ businessStatus?: string;
+ primaryTypeDisplayName?: { text?: string };
+ primaryType?: string;
+ types?: string[];
+}
+
+function locationString(city?: string, state?: string, zip?: string): string {
+ return [city?.trim(), state?.trim(), zip?.trim()].filter(Boolean).join(", ");
+}
+
+// Precision-first commercial classifier. Google's `real_estate_agency` type does
+// NOT distinguish commercial from residential, so we REQUIRE positive commercial
+// evidence (a strong name signal or an allowlisted brokerage brand) to keep a
+// result, instead of defaulting to keep. Apply order:
+// 1. Off-topic Google type (travel/lodging/storage/…) → DROP regardless.
+// 2. Financial-advisory / wealth firm WITHOUT a real-estate anchor → DROP.
+// (Runs before the commercial keep because finance names carry ambiguous
+// "investments"/"advisors"/"capital markets" tokens that would otherwise
+// satisfy COMMERCIAL_RE — e.g. "Sterling Financial Advisors" is a wealth
+// firm, not a CRE broker. An allowlisted CRE brand overrides this.)
+// 3. CRE-adjacent SERVICE / trade firm WITHOUT an explicit broker anchor →
+// DROP. (Also runs before the commercial keep: appraisal/title/escrow/
+// construction/mortgage-lending/property-management names carry
+// "commercial"/"property" tokens that satisfy COMMERCIAL_RE but are NOT
+// brokers — e.g. "Sunbelt Commercial Construction". Broker anchor or brand
+// overrides.)
+// 3.5 Explicit RESIDENTIAL name whose only commercial evidence is a vertical-
+// AMBIGUOUS generic token (brokerage/advisors/leasing/investments) → DROP.
+// Runs before the positive keep so a residential firm ("Coldwell Banker
+// Residential Brokerage") isn't rescued by "brokerage" alone. A STRONG
+// commercial token or an allowlisted brand overrides.
+// 3.6 Professional PRACTICE office ("Law/Dental/Medical Office OF Dr. …") →
+// DROP. "office" is a CRE asset-class token that would otherwise keep it;
+// the "office of <person>" structure marks a practice, not a broker.
+// Narrow (requires the "of") so legit "Office Building"/"Office Leasing"
+// CRE names are untouched. Broker anchor or brand overrides.
+// 4. Strong commercial name signal OR allowlisted brand → KEEP (wins over
+// any residential signal, e.g. "Commercial Realty Advisors").
+// 5. Residential / irrelevant name signal → DROP.
+// 6. Residential-flavored place type → DROP.
+// 7. Generic real-estate result with no commercial evidence → DROP.
+// 8. Non-real-estate result with no signal either way → DROP (nothing kept it).
+export function isCommercial(p: RawPlace): boolean {
+ const name = p.displayName?.text ?? "";
+ const types = [p.primaryType ?? "", ...(p.types ?? [])];
+
+ // 1. Off-vertical place type → drop outright (catches the travel_agency mis-tag
+ // AND every underscore-compound retail type like furniture_store/shopping_mall).
+ if (types.some((t) => isOffTopicType(t))) return false;
+
+ const hasResidentialType = types.some((t) => RESIDENTIAL_TYPES.has(t.toLowerCase()));
+
+ // 2. Financial-advisory / wealth / securities firm with no real-estate anchor →
+ // drop. Guards against the broker↔finance vertical overlap that surfaces
+ // wealth firms on a "commercial real estate broker" text search.
+ if (isFinancialAdvisoryNoRE(name)) return false;
+
+ // 3. CRE-adjacent SERVICE / trade firm with no explicit broker anchor → drop.
+ // Catches appraisal/title/escrow/construction/mortgage-lending/inspection/
+ // property-management firms whose "commercial"/"property" tokens satisfy
+ // COMMERCIAL_RE but which are NOT buyer/leasing brokers. Runs before the
+ // positive keep for the same reason as the finance gate. An allowlisted
+ // brand or a broker anchor in the name overrides (handled inside).
+ if (isCreServiceNoBroker(name)) return false;
+
+ // 3.5 Explicit RESIDENTIAL name whose only commercial evidence is a vertical-
+ // AMBIGUOUS generic token (brokerage/advisors/leasing/investments) → drop.
+ // Runs before the positive keep for the same reason as the finance/service
+ // gates: otherwise "Coldwell Banker Residential Brokerage" is kept on
+ // "brokerage" alone. A STRONG commercial token or an allowlisted brand
+ // (checked inside) overrides, so "Commercial & Home Sales Group" and
+ // "CBRE Residential" still survive.
+ if (isResidentialDespiteGeneric(name)) return false;
+
+ // 3.6 Professional PRACTICE office ("Law/Dental/Medical Office of Dr. …") → drop.
+ // "office" is a CRE asset-class token that would otherwise keep these; the
+ // "office OF <person>" structure marks a practice, not a broker. Narrow by
+ // design — legit CRE "Office Building"/"Office Leasing" names have no "of".
+ if (isProfessionalPracticeOffice(name)) return false;
+
+ // 4. Positive commercial evidence wins over everything below.
+ if (COMMERCIAL_RE.test(name) || hasCommercialBrand(name)) return true;
+
+ // 5. Explicit residential / irrelevant name → drop.
+ if (RESIDENTIAL_RE.test(name)) return false;
+
+ // 6. Residential-flavored place type with no commercial evidence → drop.
+ if (hasResidentialType) return false;
+
+ // 7 & 8. No commercial evidence at all — whether or not it's a generic
+ // real_estate_agency, we can't confirm it's commercial, so drop it.
+ // (Precision > recall: the page promises commercial-only.)
+ return false;
+}
+
+async function textSearch(query: string, apiKey: string): Promise<RawPlace[]> {
+ const res = await fetch(ENDPOINT, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Goog-Api-Key": apiKey,
+ "X-Goog-FieldMask": FIELD_MASK,
+ },
+ body: JSON.stringify({ textQuery: query, maxResultCount: 20, languageCode: "en", regionCode: "US" }),
+ cache: "no-store",
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => "");
+ throw new Error(`Places ${res.status}: ${detail.slice(0, 200)}`);
+ }
+ const json = (await res.json()) as { places?: RawPlace[] };
+ return json.places ?? [];
+}
+
+export async function searchCommercialAgents(
+ input: CommercialAgentSearch,
+): Promise<CommercialAgentResponse> {
+ const apiKey = process.env.GOOGLE_PLACES_API_KEY;
+ if (!apiKey) throw new Error("GOOGLE_PLACES_API_KEY is not configured");
+ const loc = locationString(input.city, input.state, input.zip);
+ if (!loc) throw new Error("A city, state, or ZIP is required");
+
+ const kinds = input.kinds.length ? input.kinds : (["buyer", "leasing"] as AgentKind[]);
+ const byId = new Map<string, AgentResult>();
+
+ // Build the full billed call plan: up to 3 lenses per kind. Each entry is one
+ // Places Text Search call, so `queries` = plan length = actual billed calls.
+ const plan = kinds.flatMap((kind) =>
+ KIND_LENSES[kind].map((lens) => ({ kind, query: `${lens} in ${loc}` })),
+ );
+ // Fire all lens fetches concurrently. Use allSettled so one failing lens
+ // (Google 429/500 on a single query) doesn't discard the results that DID
+ // succeed. `queries` counts only fulfilled calls — the honest billed count.
+ const settled = await Promise.allSettled(plan.map((p) => textSearch(p.query, apiKey)));
+ // If EVERY lens failed, surface the failure (route maps it to 502) rather than
+ // silently returning an empty "no agents found".
+ if (!settled.some((r) => r.status === "fulfilled")) {
+ const firstErr = settled.find((r) => r.status === "rejected") as PromiseRejectedResult | undefined;
+ throw firstErr?.reason instanceof Error ? firstErr.reason : new Error("Places search failed");
+ }
+ let queries = 0;
+
+ // Union results by place id, unioning the `kinds` array so a place surfaced by
+ // both a buyer- and leasing-lens carries both.
+ settled.forEach((r, i) => {
+ if (r.status !== "fulfilled") return;
+ queries++;
+ const places = r.value;
+ const kind = plan[i].kind;
+ for (const p of places) {
+ if (!p.id || !p.displayName?.text) continue;
+ if (p.businessStatus && p.businessStatus !== "OPERATIONAL") continue;
+ if (!isCommercial(p)) continue;
+ const existing = byId.get(p.id);
+ if (existing) {
+ if (!existing.kinds.includes(kind)) existing.kinds.push(kind);
+ continue;
+ }
+ byId.set(p.id, {
+ id: p.id,
+ name: p.displayName.text,
+ address: p.formattedAddress ?? null,
+ rating: typeof p.rating === "number" ? p.rating : null,
+ reviews: p.userRatingCount ?? 0,
+ phone: p.nationalPhoneNumber ?? null,
+ website: p.websiteUri ?? null,
+ mapsUrl: p.googleMapsUri ?? null,
+ primaryType: p.primaryTypeDisplayName?.text ?? null,
+ kinds: [kind],
+ });
+ }
+ });
+
+ // Collapse the same office surfaced under multiple lenses (same phone, or
+ // same website when phones don't contradict) BEFORE ranking.
+ const deduped = dedupeByContact([...byId.values()]);
+
+ // Rank by rating weighted by review volume AND commercial-relevance, then by
+ // relevance alone (surfaces a strong CRE match ahead of a weakly-commercial
+ // one at equal rating-weight), then rating, then review count. The relevance
+ // multiplier is bounded (≤~1.6) so it re-orders near-comparable results and
+ // breaks near-ties toward the better commercial match, but a 0-review listing
+ // still weighs rating×log(1)=0 and cannot leapfrog a genuinely reviewed office.
+ const results = deduped.sort((a, b) => {
+ const sa = (a.rating ?? 0) * Math.log((a.reviews ?? 0) + 1) * relevanceScore(a);
+ const sb = (b.rating ?? 0) * Math.log((b.reviews ?? 0) + 1) * relevanceScore(b);
+ if (sb !== sa) return sb - sa;
+ const ra = relevanceScore(a);
+ const rb = relevanceScore(b);
+ if (rb !== ra) return rb - ra;
+ if ((b.rating ?? 0) !== (a.rating ?? 0)) return (b.rating ?? 0) - (a.rating ?? 0);
+ return (b.reviews ?? 0) - (a.reviews ?? 0);
+ });
+
+ return { location: loc, kinds, results, queries };
+}
diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts
new file mode 100644
index 0000000..6f8f9bf
--- /dev/null
+++ b/src/lib/rate-limit.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it, beforeEach } from "vitest";
+import { rateLimit, __resetRateLimit } from "./rate-limit";
+
+beforeEach(() => __resetRateLimit());
+
+describe("rateLimit (sliding window)", () => {
+ it("allows up to the limit, then blocks", () => {
+ const t0 = 1_000_000;
+ for (let i = 1; i <= 5; i++) {
+ expect(rateLimit("ip:a", 5, 60_000, t0).allowed).toBe(true);
+ }
+ const sixth = rateLimit("ip:a", 5, 60_000, t0);
+ expect(sixth.allowed).toBe(false);
+ expect(sixth.remaining).toBe(0);
+ expect(sixth.retryAfterSec).toBeGreaterThan(0);
+ });
+
+ it("resets after the window elapses", () => {
+ const t0 = 2_000_000;
+ for (let i = 0; i < 5; i++) rateLimit("ip:b", 5, 60_000, t0);
+ expect(rateLimit("ip:b", 5, 60_000, t0).allowed).toBe(false);
+ // A moment past the window: fresh bucket.
+ expect(rateLimit("ip:b", 5, 60_000, t0 + 60_001).allowed).toBe(true);
+ });
+
+ it("isolates buckets by key", () => {
+ const t0 = 3_000_000;
+ for (let i = 0; i < 5; i++) rateLimit("ip:c", 5, 60_000, t0);
+ expect(rateLimit("ip:c", 5, 60_000, t0).allowed).toBe(false);
+ expect(rateLimit("ip:d", 5, 60_000, t0).allowed).toBe(true);
+ });
+});
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
new file mode 100644
index 0000000..c148844
--- /dev/null
+++ b/src/lib/rate-limit.ts
@@ -0,0 +1,76 @@
+import { NextRequest, NextResponse } from "next/server";
+
+// Lightweight in-memory sliding-window rate limiter. Per-process (correct for a
+// single self-hosted `next start` instance). For multi-instance deployments,
+// swap the store for Redis (INCR + PEXPIRE) — same interface. `now` is injectable
+// for deterministic tests.
+
+interface Bucket {
+ count: number;
+ resetAt: number;
+}
+
+const store = new Map<string, Bucket>();
+let calls = 0;
+
+function sweep(now: number) {
+ for (const [k, b] of store) if (b.resetAt <= now) store.delete(k);
+}
+
+export interface RateResult {
+ allowed: boolean;
+ remaining: number;
+ limit: number;
+ retryAfterSec: number;
+}
+
+export function rateLimit(key: string, limit: number, windowMs: number, now = Date.now()): RateResult {
+ // Opportunistic cleanup so the map can't grow unbounded.
+ if (++calls % 500 === 0) sweep(now);
+
+ let b = store.get(key);
+ if (!b || b.resetAt <= now) {
+ b = { count: 0, resetAt: now + windowMs };
+ store.set(key, b);
+ }
+ b.count++;
+ const allowed = b.count <= limit;
+ return {
+ allowed,
+ remaining: Math.max(0, limit - b.count),
+ limit,
+ retryAfterSec: Math.ceil((b.resetAt - now) / 1000),
+ };
+}
+
+/** Best-effort client IP from proxy headers (nginx sets x-forwarded-for). */
+export function clientIp(req: NextRequest): string {
+ const fwd = req.headers.get("x-forwarded-for");
+ if (fwd) return fwd.split(",")[0].trim();
+ return req.headers.get("x-real-ip") || "unknown";
+}
+
+/**
+ * Build a rate-limit bucket key that prefers the UNFORGEABLE session subject
+ * over the spoofable client IP. A logged-in user is keyed on their verified
+ * `sub` so they can't reset their bucket (and run up the paid Places bill) by
+ * rotating X-Forwarded-For; only anonymous/last-resort callers fall back to IP.
+ * Pure + exported so the security invariant is unit-testable.
+ */
+export function sessionOrIpKey(prefix: string, userSub: string | null | undefined, ip: string): string {
+ return userSub ? `${prefix}:u:${userSub}` : `${prefix}:ip:${ip}`;
+}
+
+/** 429 response with a Retry-After header. */
+export function tooManyRequests(r: RateResult, extraHeaders?: Record<string, string>): NextResponse {
+ return NextResponse.json(
+ { error: "Too many requests. Please slow down." },
+ { status: 429, headers: { "Retry-After": String(r.retryAfterSec), ...extraHeaders } },
+ );
+}
+
+/** Test-only: clear all buckets. */
+export function __resetRateLimit() {
+ store.clear();
+ calls = 0;
+}
diff --git a/src/lib/reports.ts b/src/lib/reports.ts
new file mode 100644
index 0000000..093b7b6
--- /dev/null
+++ b/src/lib/reports.ts
@@ -0,0 +1,139 @@
+import { prisma } from "@/lib/db";
+
+export interface GroupPerf {
+ key: string;
+ count: number;
+ avgRoi: number;
+ totalNet: number;
+}
+
+export interface Reports {
+ totalExpectedProfit: number;
+ avgRoi: number;
+ cashRequired: number;
+ inventoryByStatus: { status: string; count: number }[];
+ categoryPerformance: GroupPerf[];
+ sourcePerformance: GroupPerf[];
+ auctionSuccessRate: number; // won / (won + lost)
+ estimatedVsActual: { title: string; estimated: number; actual: number }[];
+}
+
+const num = (d: unknown) => (d == null ? 0 : Number(d));
+
+export async function getReports(): Promise<Reports> {
+ // Run all independent DB queries in parallel.
+ const [
+ costAgg,
+ inventoryGroups,
+ outcomeWonLost,
+ recentOutcomes,
+ ] = await Promise.all([
+ // Top-line aggregates over all listings that have a costBreakdown.
+ // These are intentionally all-time (not ACTIVE-scoped) so the report
+ // reflects the full pipeline value, not just currently live items.
+ prisma.costBreakdown.aggregate({
+ _sum: { expectedNetProfit: true, recommendedMaxBid: true, roi: true },
+ _avg: { roi: true },
+ _count: { id: true },
+ }),
+
+ // inventoryByStatus must cover ALL statuses (ACTIVE/ENDED/REMOVED) —
+ // scoping to ACTIVE would collapse all non-ACTIVE rows and lose the
+ // breakdown that the reports page displays.
+ prisma.listing.groupBy({
+ by: ["researchStatus"],
+ _count: { id: true },
+ }),
+
+ // Outcome counts for success-rate denominator (WON + LOST only).
+ prisma.auctionOutcome.groupBy({
+ by: ["status"],
+ _count: { id: true },
+ }),
+
+ // estimatedVsActual: only outcomes where actualSale is set. Bounded at
+ // 500 rows — the chart is not intended to render thousands of data points.
+ prisma.auctionOutcome.findMany({
+ where: { actualSale: { not: null } },
+ include: { listing: { select: { title: true } } },
+ orderBy: { createdAt: "desc" },
+ take: 500,
+ }),
+ ]);
+
+ // ---- top-line numbers ------------------------------------------------
+ const totalExpectedProfit = num(costAgg._sum.expectedNetProfit);
+ const cashRequired = num(costAgg._sum.recommendedMaxBid);
+ const avgRoi = num(costAgg._avg.roi);
+
+ // ---- inventory by research status ------------------------------------
+ const inventoryByStatus = inventoryGroups.map((g) => ({
+ status: g.researchStatus,
+ count: g._count.id,
+ }));
+
+ // ---- category / source performance -----------------------------------
+ // Prisma groupBy cannot traverse relations, so we cannot group CostBreakdown
+ // by Listing.category/source in a single DB aggregation without a schema
+ // change. Instead, fetch CostBreakdown rows with the needed Listing fields
+ // (capped at 5 000) and aggregate in JS — same semantics as the original
+ // but with a hard safety cap instead of an unbounded load.
+ const costRows = await prisma.costBreakdown.findMany({
+ select: {
+ roi: true,
+ expectedNetProfit: true,
+ listing: { select: { category: true, source: true } },
+ },
+ take: 5000,
+ });
+ if (costRows.length === 5000) {
+ // Not silent: category/source performance undercounts past the cap. Durable
+ // fix is a raw-SQL GROUP BY across the Listing↔CostBreakdown relation.
+ console.warn("[getReports] hit 5000-row cost cap; category/source performance may undercount");
+ }
+
+ const buildGroupPerf = (keyFn: (r: (typeof costRows)[number]) => string): GroupPerf[] => {
+ const m = new Map<string, { count: number; roiSum: number; net: number }>();
+ for (const r of costRows) {
+ const k = keyFn(r) || "Uncategorized";
+ const g = m.get(k) ?? { count: 0, roiSum: 0, net: 0 };
+ g.count++;
+ g.roiSum += num(r.roi);
+ g.net += num(r.expectedNetProfit);
+ m.set(k, g);
+ }
+ return [...m]
+ .map(([key, g]) => ({ key, count: g.count, avgRoi: g.roiSum / g.count, totalNet: g.net }))
+ .sort((a, b) => b.totalNet - a.totalNet);
+ };
+
+ const categoryPerformance = buildGroupPerf((r) => r.listing.category ?? "Uncategorized");
+ const sourcePerformance = buildGroupPerf((r) => r.listing.source);
+
+ // ---- auction success rate --------------------------------------------
+ let won = 0;
+ let lost = 0;
+ for (const g of outcomeWonLost) {
+ if (g.status === "WON") won = g._count.id;
+ if (g.status === "LOST") lost = g._count.id;
+ }
+ const auctionSuccessRate = won + lost > 0 ? won / (won + lost) : 0;
+
+ // ---- estimated vs actual --------------------------------------------
+ const estimatedVsActual = recentOutcomes.map((o) => ({
+ title: o.listing.title,
+ estimated: num(o.maxBidSet),
+ actual: num(o.actualSale),
+ }));
+
+ return {
+ totalExpectedProfit,
+ avgRoi,
+ cashRequired,
+ inventoryByStatus,
+ categoryPerformance,
+ sourcePerformance,
+ auctionSuccessRate,
+ estimatedVsActual,
+ };
+}
diff --git a/src/lib/selling-avenues.ts b/src/lib/selling-avenues.ts
new file mode 100644
index 0000000..f7189b2
--- /dev/null
+++ b/src/lib/selling-avenues.ts
@@ -0,0 +1,168 @@
+// Structured "where to source" and "where to sell / validate demand before you
+// buy" reference, with real links. Rendered at /selling-avenues and mirrored in
+// docs/SELLING-AVENUES.md. Compliance-first: you may validate demand and take
+// CONTINGENT interest before winning, but you must never represent ownership of
+// an item you have not yet won.
+
+export interface Avenue {
+ name: string;
+ url: string;
+ note: string;
+}
+
+export interface AvenueGroup {
+ heading: string;
+ blurb: string;
+ avenues: Avenue[];
+}
+
+export const SOURCING: AvenueGroup[] = [
+ {
+ heading: "Federal surplus",
+ blurb: "US government agency surplus — often no buyer's premium.",
+ avenues: [
+ { name: "GSA Auctions", url: "https://gsaauctions.gov", note: "Federal personal property; frequently $0 buyer premium." },
+ { name: "GovDeals", url: "https://www.govdeals.com", note: "Largest gov-surplus marketplace (state/county/federal); ~10% premium." },
+ { name: "GovPlanet / AllSurplus", url: "https://www.govplanet.com", note: "Heavy equipment, vehicles, military rolling stock." },
+ ],
+ },
+ {
+ heading: "State & county surplus",
+ blurb: "State DGS/facilities programs and county auctions.",
+ avenues: [
+ { name: "Public Surplus", url: "https://www.publicsurplus.com", note: "Municipal, school-district and agency surplus nationwide." },
+ { name: "California DGS Surplus", url: "https://www.dgs.ca.gov/OFAM/Surplus-Property", note: "State of California surplus personal property." },
+ { name: "Texas Facilities Commission Surplus", url: "https://www.tfc.texas.gov/divisions/supportserv/prog/statesurplus/", note: "Texas state surplus program." },
+ { name: "Municibid", url: "https://www.municibid.com", note: "Local government surplus — vehicles, equipment." },
+ { name: "Bid4Assets", url: "https://www.bid4assets.com", note: "County tax-defaulted property + surplus." },
+ ],
+ },
+ {
+ heading: "University surplus",
+ blurb: "University asset-recovery programs, strong for lab & IT gear.",
+ avenues: [
+ { name: "UW Surplus", url: "https://surplus.uw.edu", note: "University of Washington." },
+ { name: "MSU Surplus Store", url: "https://surplus.msu.edu", note: "Michigan State University." },
+ { name: "UC Davis Aggie Surplus", url: "https://aggiesurplus.ucdavis.edu", note: "UC Davis." },
+ ],
+ },
+];
+
+export const SELLING: AvenueGroup[] = [
+ {
+ heading: "Retail resale marketplaces",
+ blurb: "Where the flipped item actually gets sold.",
+ avenues: [
+ { name: "eBay", url: "https://www.ebay.com", note: "Deepest buyer pool for used equipment; ~13% final-value fee." },
+ { name: "Facebook Marketplace", url: "https://www.facebook.com/marketplace", note: "Best for local, heavy, freight-averse items." },
+ { name: "Amazon Renewed", url: "https://www.amazon.com/renewed", note: "Refurb electronics at scale (approval required)." },
+ { name: "DOTmed / LabX", url: "https://www.dotmed.com", note: "Medical & lab equipment verticals — higher-value buyers." },
+ { name: "MachineryTrader", url: "https://www.machinerytrader.com", note: "Industrial / heavy equipment." },
+ ],
+ },
+ {
+ heading: "Wholesale & liquidation exits",
+ blurb: "Faster, lower-price exits when you don't want to retail one-by-one.",
+ avenues: [
+ { name: "B-Stock", url: "https://www.bstock.com", note: "Bulk B2B liquidation auctions." },
+ { name: "Liquidation.com", url: "https://www.liquidation.com", note: "Pallet/lot liquidation." },
+ ],
+ },
+ {
+ heading: "Demand validation (BEFORE you buy)",
+ blurb:
+ "Use these to prove resale price and demand before committing to a bid — this is research, not a sale.",
+ avenues: [
+ { name: "eBay Terapeak / Sold listings", url: "https://www.ebay.com/sh/research", note: "Free sold-price history — the single best comp source." },
+ { name: "Google Shopping", url: "https://shopping.google.com", note: "New-retail anchor across sellers." },
+ { name: "PriceCharting", url: "https://www.pricecharting.com", note: "For collectibles/electronics with model-level pricing." },
+ ],
+ },
+];
+
+// International sourcing + resale venues — government/industrial surplus auctions
+// and the dominant secondary marketplaces by region.
+export const INTERNATIONAL: AvenueGroup[] = [
+ {
+ heading: "Europe",
+ blurb: "Pan-European industrial/surplus auctions + high-end curated marketplaces.",
+ avenues: [
+ { name: "Troostwijk Auctions", url: "https://www.troostwijkauctions.com/en", note: "NL-based, industrial/machine surplus across 10+ EU countries." },
+ { name: "TBAuctions (BVA)", url: "https://tbauctions.com/brand-portfolio/", note: "Leading B2B auction group — BVA, Vavato, Klaravik, etc." },
+ { name: "Vavato", url: "https://www.vavato.com", note: "Belgium — industrial, overstock, bankruptcy goods." },
+ { name: "GovPlanet Europe", url: "https://www.govplanet.eu/", note: "Government + military surplus, EU." },
+ { name: "Catawiki", url: "https://www.catawiki.com/en/", note: "Curated high-end auctions (art, design, collectibles)." },
+ { name: "Ritchie Bros", url: "https://www.rbauction.com", note: "Global heavy equipment (EU yards)." },
+ ],
+ },
+ {
+ heading: "Hong Kong",
+ blurb: "Government surplus + the region's top resale app.",
+ avenues: [
+ { name: "HK Gov Logistics Dept — Public Auction", url: "https://www.gld.gov.hk/en/our-services/supplies/auction/", note: "Government surplus/unserviceable stores + confiscated goods." },
+ { name: "Carousell HK", url: "https://www.carousell.com.hk", note: "Dominant HK second-hand marketplace." },
+ ],
+ },
+ {
+ heading: "Japan",
+ blurb: "The largest JP auction + resale platforms (also host government auctions).",
+ avenues: [
+ { name: "Yahoo! Auctions Japan", url: "https://auctions.yahoo.co.jp", note: "Dominant JP auction site; also runs 官公庁 government auctions." },
+ { name: "Mercari Japan", url: "https://jp.mercari.com", note: "Largest JP C2C resale marketplace." },
+ ],
+ },
+ {
+ heading: "Australia",
+ blurb: "Government, council, ex-military and salvage auctions.",
+ avenues: [
+ { name: "GraysOnline", url: "https://www.graysonline.com", note: "Government/council/industrial + clearance auctions." },
+ { name: "Pickles", url: "https://www.pickles.com.au", note: "Vehicles, industrial, salvage, Defence surplus." },
+ { name: "Manheim Australia", url: "https://www.manheim.com.au", note: "Fleet + government vehicle auctions." },
+ ],
+ },
+ {
+ heading: "China (high-end & judicial)",
+ blurb: "Court-seized/judicial asset auctions + luxury/art houses in Beijing/Shanghai/Shenzhen.",
+ avenues: [
+ { name: "Taobao Judicial (阿里资产/司法拍卖)", url: "https://sf.taobao.com", note: "Alibaba court-seized asset auctions — property, vehicles, luxury." },
+ { name: "JD Auction (京东拍卖)", url: "https://auction.jd.com", note: "JD judicial + surplus auctions." },
+ { name: "Poly Auction", url: "https://www.polyauction.com", note: "High-end art/collectibles (Beijing/HK)." },
+ { name: "Alibaba", url: "https://www.alibaba.com", note: "Wholesale sourcing + resale reference pricing." },
+ ],
+ },
+];
+
+export interface ComplianceRule {
+ title: string;
+ body: string;
+}
+
+export const COMPLIANCE: ComplianceRule[] = [
+ {
+ title: "Never represent ownership before you win",
+ body:
+ "You do not own an auction lot until the auction closes in your favor and you have paid. Any marketing before that must be framed as CONTINGENT interest ('I intend to acquire this; contingent on winning'), never 'for sale now'.",
+ },
+ {
+ title: "eBay pre-sale rules",
+ body:
+ "eBay allows pre-sale listings only for items you have a firm commitment to supply and can ship within 40 business days (listing must state the ship date). You cannot list an auction lot you might lose. Practically: validate demand and collect contingent buyer interest off-platform, then list once you actually win and hold the item.",
+ },
+ {
+ title: "Contingent buyer interest is fine",
+ body:
+ "Collecting names, emails and non-binding offers on a 'buyer interest' page is compliant as long as it is clearly contingent and non-binding until you own the item. GovArbitrage's buyer workflow enforces the contingent flag and shows a disclaimer on every interest page.",
+ },
+ {
+ title: "Government auction terms bind you",
+ body:
+ "Gov surplus is sold AS-IS, often pickup-only, with removal deadlines and payment windows. Model those costs (pickup labor, freight, storage) before bidding — the cost engine does this. Missing a removal deadline can forfeit the item and your payment.",
+ },
+];
+
+// The worked example Steve gave: a dental chair bought cheap vs. new retail.
+export const WORKED_EXAMPLE = {
+ title: "Worked example — the dental chair",
+ body:
+ "An A-dec 511 dental patient chair sells new for ~$20,000. A county-clinic surplus unit in used-good condition might hammer at $500 on GovDeals. But 'buy $500, sell $20,000' is fiction. Realistic used A-dec 511 units sell for ~$6,000–9,000 (see eBay/DOTmed sold comps). Against that, subtract the 10% buyer's premium, ~$500 in freight (320 lb, pickup-only), pickup labor, testing, refurbishment, ~13% marketplace fees and payment fees. GovArbitrage runs exactly this math per listing and reports the honest expected net profit, ROI, and a recommended maximum bid — so you bid to a target return instead of a fantasy spread.",
+};
diff --git a/src/lib/send-digest.ts b/src/lib/send-digest.ts
new file mode 100644
index 0000000..dbd9983
--- /dev/null
+++ b/src/lib/send-digest.ts
@@ -0,0 +1,88 @@
+import { prisma } from "@/lib/db";
+import { findHotDeals, type HotDeal } from "@/lib/hot-deals";
+import { sendNewsletterEmail, newsletterSendMode } from "@/lib/newsletter";
+import { persistDigestSnapshot, currentSlot } from "@/lib/digest-snapshot";
+
+// Digest send-to-list. Renders the same honesty-gated Top-10 the paid alerts
+// use and fans it out to every CONFIRMED subscriber, each with their own
+// one-click unsubscribe link (CAN-SPAM). All sends go through
+// sendNewsletterEmail(), which is TEST-logged by default and throws on an
+// unapproved live flip — so calling this is safe until Steve approves live
+// transport. Nothing schedules this yet; the launchd/cron wiring is part of
+// the gated go-live (see pending-approval memo).
+
+const fmtUsd = (n: number) => `$${Math.round(n).toLocaleString("en-US")}`;
+
+export function renderDigestText(deals: HotDeal[], unsubscribeUrl: string): string {
+ const lines: string[] = [
+ "GovArbitrage — Top-10 Deals Digest",
+ new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }),
+ "",
+ ];
+
+ deals.forEach((d, i) => {
+ const where = [d.listing.locationCity, d.listing.locationState].filter(Boolean).join(", ");
+ lines.push(
+ `${i + 1}. ${d.listing.title}`,
+ ` Current bid ${fmtUsd(d.currentBid)} · rec. max bid ${fmtUsd(d.recMax)} · ` +
+ `est. resale ${fmtUsd(d.expectedSaleLow)} (${d.confidence} confidence)`,
+ ` Conservative ROI ${(d.roiConservative * 100).toFixed(0)}%${d.roiCapped ? " (capped)" : ""} · ${d.disclaimer}`,
+ ...(d.listing.sourceUrl ? [` ${d.listing.sourceUrl}`] : []),
+ ...(where ? [` Location: ${where}`] : []),
+ ""
+ );
+ });
+
+ if (!deals.length) lines.push("No deals cleared the hot-deal gate this run.", "");
+
+ lines.push(
+ "—",
+ "Every figure above is confidence-discounted and capped, never inflated.",
+ "Estimates are heuristic — always verify comps before bidding.",
+ "",
+ `Unsubscribe (one click, effective immediately): ${unsubscribeUrl}`
+ );
+ return lines.join("\n");
+}
+
+export interface DigestSendResult {
+ mode: "test" | "live";
+ subscribers: number;
+ sent: number;
+ errors: number;
+ dealCount: number;
+}
+
+export async function sendDigestToSubscribers(opts: { baseUrl?: string; limit?: number } = {}): Promise<DigestSendResult> {
+ const baseUrl = opts.baseUrl || process.env.NEWSLETTER_BASE_URL;
+ // Never let a live send fabricate localhost unsubscribe links — a broken
+ // unsubscribe URL in a real email is a CAN-SPAM problem, not a dev nit.
+ if (!baseUrl || (newsletterSendMode() === "live" && !baseUrl.startsWith("https://"))) {
+ throw new Error("sendDigestToSubscribers needs a public NEWSLETTER_BASE_URL (https) — refusing to build localhost links.");
+ }
+ const deals = await findHotDeals({ limit: opts.limit ?? 10 });
+
+ // Freeze this edition for the public /deals archive — even if every send
+ // below fails, the archive records what the digest showed at send time.
+ await persistDigestSnapshot(currentSlot(), deals);
+
+ const subscribers = await prisma.subscriber.findMany({ where: { status: "CONFIRMED" } });
+
+ let sent = 0;
+ let errors = 0;
+ for (const sub of subscribers) {
+ const unsubscribeUrl = `${baseUrl}/api/newsletter/unsubscribe?token=${sub.unsubscribeToken}`;
+ try {
+ await sendNewsletterEmail({
+ to: sub.email,
+ subject: `Top-10 gov-surplus deals — ${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}`,
+ text: renderDigestText(deals, unsubscribeUrl),
+ });
+ sent++;
+ } catch {
+ errors++;
+ }
+ }
+
+ return { mode: newsletterSendMode(), subscribers: subscribers.length, sent, errors, dealCount: deals.length };
+}
diff --git a/src/lib/session.ts b/src/lib/session.ts
new file mode 100644
index 0000000..0e56207
--- /dev/null
+++ b/src/lib/session.ts
@@ -0,0 +1,42 @@
+import { SignJWT, jwtVerify } from "jose";
+
+// JWT session tokens (HS256) signed with AUTH_SECRET. Pure-JS (jose / Web
+// Crypto) so this module works in BOTH the Edge middleware and Node handlers.
+
+export const SESSION_COOKIE = "ga_session";
+export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
+
+export interface SessionPayload {
+ sub: string; // user id
+ email: string;
+ role: string; // ADMIN | ANALYST | VIEWER
+}
+
+function secret(): Uint8Array {
+ const s = process.env.AUTH_SECRET;
+ // The dev fallback is in the repo — a production deploy that forgot to set
+ // AUTH_SECRET would mint forge-trivial sessions. Refuse to run instead.
+ if (!s && process.env.NODE_ENV === "production") {
+ throw new Error("AUTH_SECRET must be set in production — refusing to sign sessions with the dev fallback.");
+ }
+ return new TextEncoder().encode(s || "dev-only-change-me");
+}
+
+export async function createSession(payload: SessionPayload): Promise<string> {
+ return new SignJWT({ email: payload.email, role: payload.role })
+ .setProtectedHeader({ alg: "HS256" })
+ .setSubject(payload.sub)
+ .setIssuedAt()
+ .setExpirationTime(`${SESSION_MAX_AGE}s`)
+ .sign(secret());
+}
+
+export async function verifySession(token: string): Promise<SessionPayload | null> {
+ try {
+ const { payload } = await jwtVerify(token, secret());
+ if (!payload.sub || !payload.email) return null;
+ return { sub: payload.sub, email: payload.email as string, role: (payload.role as string) ?? "VIEWER" };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/site.ts b/src/lib/site.ts
new file mode 100644
index 0000000..6062e18
--- /dev/null
+++ b/src/lib/site.ts
@@ -0,0 +1,9 @@
+// Public origin for canonical URLs, sitemap, robots, and OG tags.
+// NEWSLETTER_BASE_URL is already the env of record for public links
+// (confirm/unsubscribe emails use it) — reuse it rather than adding a twin.
+export function publicBaseUrl(): string {
+ const env = process.env.NEWSLETTER_BASE_URL;
+ if (env) return env.replace(/\/+$/, "");
+ if (process.env.NODE_ENV === "production") return "https://auctions.agentabrams.com";
+ return "http://localhost:3737";
+}
diff --git a/src/lib/ssrf-guard.ts b/src/lib/ssrf-guard.ts
new file mode 100644
index 0000000..8e76593
--- /dev/null
+++ b/src/lib/ssrf-guard.ts
@@ -0,0 +1,104 @@
+import { lookup } from "node:dns/promises";
+
+// SSRF guard for the URL importer. Validates that a user-supplied URL points at
+// a publicly-routable host before Playwright is pointed at it, blocking cloud
+// metadata endpoints (169.254.169.254 / metadata.google.internal), localhost,
+// and RFC1918 / loopback / link-local / ULA ranges. Node-runtime only (uses
+// node:dns/promises) — do NOT import from Edge middleware.
+
+function ipv4ToInt(ip: string): number | null {
+ const parts = ip.split(".");
+ if (parts.length !== 4) return null;
+ let n = 0;
+ for (const p of parts) {
+ if (!/^\d+$/.test(p)) return null;
+ const o = Number(p);
+ if (o < 0 || o > 255) return null;
+ n = n * 256 + o;
+ }
+ return n >>> 0;
+}
+
+function isPrivateIPv4(ip: string): boolean {
+ const n = ipv4ToInt(ip);
+ if (n === null) return false;
+ const inRange = (base: string, bits: number) => {
+ const baseInt = ipv4ToInt(base)!;
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
+ return (n & mask) === (baseInt & mask);
+ };
+ return (
+ inRange("10.0.0.0", 8) ||
+ inRange("172.16.0.0", 12) ||
+ inRange("192.168.0.0", 16) ||
+ inRange("127.0.0.0", 8) ||
+ inRange("169.254.0.0", 16) ||
+ inRange("0.0.0.0", 8)
+ );
+}
+
+function isPrivateIPv6(addr: string): boolean {
+ const ip = addr.toLowerCase().replace(/^\[|\]$/g, "");
+ // IPv4-mapped (::ffff:a.b.c.d) — defer to the IPv4 check.
+ const mapped = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
+ if (mapped) return isPrivateIPv4(mapped[1]);
+ if (ip === "::1" || ip === "::") return true; // loopback / unspecified
+ const first = ip.split(":")[0];
+ const hi = parseInt((first || "0").padStart(4, "0"), 16);
+ if ((hi & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local (fe80..febf)
+ if ((hi & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local (fc00..fdff)
+ return false;
+}
+
+function isBlockedAddress(addr: string): boolean {
+ return addr.includes(":") ? isPrivateIPv6(addr) : isPrivateIPv4(addr);
+}
+
+/**
+ * Parse and validate a user-supplied URL, throwing when it targets a
+ * non-public destination. Returns the parsed URL on success.
+ */
+export async function assertPublicUrl(rawUrl: string): Promise<URL> {
+ let url: URL;
+ try {
+ url = new URL(rawUrl);
+ } catch {
+ throw new Error("Invalid URL");
+ }
+
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error(`Blocked URL scheme: ${url.protocol}`);
+ }
+
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
+ if (
+ host === "localhost" ||
+ host.endsWith(".internal") ||
+ host === "metadata.google.internal"
+ ) {
+ throw new Error(`Blocked hostname: ${host}`);
+ }
+
+ // A literal IP in the host still needs to pass the range check.
+ if (isBlockedAddress(host)) {
+ throw new Error(`Blocked private address: ${host}`);
+ }
+
+ // Resolve the hostname and reject if ANY address is private/loopback/etc.
+ let addresses: { address: string }[];
+ try {
+ addresses = await lookup(host, { all: true });
+ } catch {
+ throw new Error(`Could not resolve hostname: ${host}`);
+ }
+ if (addresses.length === 0) {
+ throw new Error(`Could not resolve hostname: ${host}`);
+ }
+ for (const { address } of addresses) {
+ if (isBlockedAddress(address)) {
+ throw new Error(`Blocked private address for ${host}: ${address}`);
+ }
+ }
+
+ return url;
+}
diff --git a/src/lib/tiers.ts b/src/lib/tiers.ts
new file mode 100644
index 0000000..21461fc
--- /dev/null
+++ b/src/lib/tiers.ts
@@ -0,0 +1,105 @@
+// 3-tier paid-alerts SaaS config — the single source of truth for pricing,
+// features, and enforceable data limits. Prices are DRAFT (Steve sets final);
+// live charging stays gated (billing runs TEST/mock until a live key is set).
+import type { Tier } from "@prisma/client";
+
+export interface TierDef {
+ key: Tier;
+ label: string;
+ priceUsd: number; // per month; 0 = free
+ blurb: string;
+ features: string[];
+ limits: {
+ maxListings: number; // rows the dashboard/API returns (Infinity via a big number)
+ freshnessDelayHours: number; // Free sees deals aged by this many hours (not real-time)
+ showMoneyMath: boolean; // recommended max bid / ROI / net profit visible?
+ hotDealAlerts: boolean; // receive the 🔥 email alerts?
+ realtime: boolean; // real-time vs delayed
+ allSources: boolean; // all 8 feeds vs a limited subset
+ };
+}
+
+// env override for prices without a code change: TIER_PRICE_STANDARD, TIER_PRICE_PREMIUM
+function price(env: string, dflt: number): number {
+ const v = process.env[env];
+ const n = v == null ? NaN : Number(v);
+ return Number.isFinite(n) && n >= 0 ? n : dflt;
+}
+
+export const TIERS: Record<Tier, TierDef> = {
+ FREE: {
+ key: "FREE",
+ label: "Free",
+ priceUsd: 0,
+ blurb: "See the full analysis on every deal — on a delay.",
+ features: [
+ "Browse the auction catalog",
+ "Full money-math: recommended max bid, ROI, net profit, scores",
+ "Deals delayed 24 hours",
+ "Up to 25 listings",
+ "No email alerts",
+ ],
+ limits: {
+ maxListings: 25,
+ freshnessDelayHours: 24,
+ showMoneyMath: true,
+ hotDealAlerts: false,
+ realtime: false,
+ allSources: false,
+ },
+ },
+ STANDARD: {
+ key: "STANDARD",
+ label: "Standard",
+ priceUsd: price("TIER_PRICE_STANDARD", 19),
+ blurb: "Real-time deals + the daily hot-deal alert.",
+ features: [
+ "Real-time listings (no delay)",
+ "🔥 Daily hot-deal email alerts",
+ "Up to 500 listings",
+ "All 8 auction sources",
+ ],
+ limits: {
+ maxListings: 500,
+ freshnessDelayHours: 0,
+ showMoneyMath: true,
+ hotDealAlerts: true,
+ realtime: true,
+ allSources: true,
+ },
+ },
+ PREMIUM: {
+ key: "PREMIUM",
+ label: "Premium",
+ priceUsd: price("TIER_PRICE_PREMIUM", 49),
+ blurb: "Everything, unlimited, with confidence bands + priority.",
+ features: [
+ "Everything in Standard",
+ "Unlimited listings",
+ "Honesty-Gate confidence bands on every deal",
+ "Full dashboard + saved views + exports",
+ "Priority hot-deal alerts (sent first)",
+ ],
+ limits: {
+ maxListings: 1_000_000,
+ freshnessDelayHours: 0,
+ showMoneyMath: true,
+ hotDealAlerts: true,
+ realtime: true,
+ allSources: true,
+ },
+ },
+};
+
+export const TIER_ORDER: Tier[] = ["FREE", "STANDARD", "PREMIUM"];
+
+export function tierDef(t: Tier | string | null | undefined): TierDef {
+ return TIERS[(t as Tier) in TIERS ? (t as Tier) : "FREE"];
+}
+
+/** Map a Stripe price/lookup to a tier (used by the webhook). */
+export function tierFromPriceUsd(usd: number): Tier {
+ if (usd >= TIERS.PREMIUM.priceUsd && TIERS.PREMIUM.priceUsd > 0) return "PREMIUM";
+ if (usd >= TIERS.STANDARD.priceUsd && TIERS.STANDARD.priceUsd > 0) return "STANDARD";
+ return "FREE";
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..d39b59b
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,50 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/** shadcn/ui-style className combiner. */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/** Round to whole cents (avoids float drift; all engine money is in dollars). */
+export function round2(n: number): number {
+ return Math.round((n + Number.EPSILON) * 100) / 100;
+}
+
+export function clamp(n: number, lo: number, hi: number): number {
+ return Math.min(hi, Math.max(lo, n));
+}
+
+const usd = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 0,
+});
+const usdCents = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+});
+
+export function formatMoney(n: number | null | undefined, cents = false): string {
+ if (n === null || n === undefined || Number.isNaN(n)) return "—";
+ return cents ? usdCents.format(n) : usd.format(n);
+}
+
+export function formatPercent(ratio: number | null | undefined, digits = 0): string {
+ if (ratio === null || ratio === undefined || Number.isNaN(ratio)) return "—";
+ return `${(ratio * 100).toFixed(digits)}%`;
+}
+
+/** Human countdown to a future date, e.g. "2d 4h" or "Closed". */
+export function countdown(to: Date | string | null | undefined, now = new Date()): string {
+ if (!to) return "—";
+ const target = typeof to === "string" ? new Date(to) : to;
+ const ms = target.getTime() - now.getTime();
+ if (ms <= 0) return "Closed";
+ const d = Math.floor(ms / 86_400_000);
+ const h = Math.floor((ms % 86_400_000) / 3_600_000);
+ const m = Math.floor((ms % 3_600_000) / 60_000);
+ if (d > 0) return `${d}d ${h}h`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..56f9b22
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,132 @@
+import { NextRequest, NextResponse } from "next/server";
+import { SESSION_COOKIE, SESSION_MAX_AGE, createSession, verifySession } from "@/lib/session";
+import { FLEET_SSO_COOKIE, fleetSsoOk } from "@/lib/fleet-sso";
+
+// Public paths that never require a session:
+// - /login and the auth API
+// - /b/<slug> public contingent buyer-interest pages
+// - the public buyer-lead submission endpoint (anonymous buyers)
+const PUBLIC = [
+ /^\/login(?:\/|$)/,
+ /^\/api\/auth\//,
+ /^\/b\//,
+ // Public read-only catalog API the iOS app + any browser call anonymously.
+ // Same data for every client (money-math is tier-driven, FREE includes it);
+ // GET-only routes, so exposing them carries no write surface. Keeping this
+ // genuinely public — no shared password, no auto-minted session — is what
+ // makes the app's data-access behavior identical and demonstrable to review.
+ /^\/api\/listings(?:\/|$)/,
+ /^\/api\/listings\/[^/]+\/buyer-lead$/,
+ /^\/pricing(?:\/|$)/, // public marketing page (upgrade CTA)
+ /^\/api\/billing\/webhook$/, // Stripe calls this server-to-server, no session
+ /^\/newsletter(?:\/|$)/, // public digest subscribe landing
+ /^\/api\/newsletter\/(?:subscribe|confirm|unsubscribe)$/, // anonymous subscribers + email links
+ /^\/deals(?:\/|$)/, // public SEO digest archive (frozen snapshots, teaser numbers only)
+ /^\/selling-avenues(?:\/|$)/, // public SEO reference (where/how to resell surplus) — crawlable, in sitemap
+ /^\/privacy(?:\/|$)/, // public privacy policy — REQUIRED reachable w/o login for the App Store listing URL
+ /^\/sitemap\.xml$/, // crawlers (robots.txt is already excluded by the matcher)
+ /^\/robots\.txt$/,
+];
+
+function isPublic(pathname: string): boolean {
+ return PUBLIC.some((re) => re.test(pathname));
+}
+
+// Outer un/pw wall for the whole site (ideas/fleet-unified admin:DW2024!).
+// Override with BASIC_AUTH="user:pass"; BASIC_AUTH="" disables. Runs in Edge → use atob, not Buffer.
+const BASIC_AUTH = process.env.BASIC_AUTH ?? "admin:DW2024!";
+function basicAuthOk(req: NextRequest): boolean {
+ if (!BASIC_AUTH) return true;
+ const m = (req.headers.get("authorization") || "").match(/^Basic\s+(.+)$/i);
+ if (!m) return false;
+ try { return atob(m[1]) === BASIC_AUTH; } catch { return false; }
+}
+
+export async function middleware(req: NextRequest) {
+ const { pathname } = req.nextUrl;
+
+ // Machine callers that cannot present a password bypass the wall:
+ // Stripe's server-to-server webhook and the extension/CSV import-token clients.
+ const importToken = process.env.IMPORT_TOKEN;
+ const machineOk =
+ pathname === "/api/billing/webhook" ||
+ (!!importToken && req.headers.get("x-import-token") === importToken);
+ // Public paths (login, privacy, the anonymous read API, SEO pages) must be
+ // reachable without the shared admin wall — otherwise the "public" API would
+ // silently require a password only insiders know, the kind of hidden gate
+ // Guideline 5.6 flags. The wall still guards every non-public surface.
+ if (!machineOk && !isPublic(pathname) && !basicAuthOk(req)) {
+ return new NextResponse("auth required", {
+ status: 401,
+ headers: { "WWW-Authenticate": 'Basic realm="GovArbitrage"' },
+ });
+ }
+
+ if (isPublic(pathname)) return NextResponse.next();
+
+ // Valid session cookie?
+ const token = req.cookies.get(SESSION_COOKIE)?.value;
+ const session = token ? await verifySession(token) : null;
+
+ // Machine clients (extension / CSV import) may present the import token.
+ const hasImportToken = !!importToken && req.headers.get("x-import-token") === importToken;
+
+ if (session || hasImportToken) return NextResponse.next();
+
+ // Basic-auth IS the login (Steve 2026-07-27: "my basic un and pw, not the email
+ // login"). Any human who cleared the outer un/pw wall (the BASIC_AUTH creds) is
+ // treated as ADMIN — we mint a real ADMIN session here so the app never bounces to the
+ // email /login form. basicAuthOk is already true for every non-public/non-machine
+ // request that reached this line (the wall 401s otherwise); the guard just makes
+ // sure a machine-token-only caller doesn't get an admin cookie.
+ if (basicAuthOk(req)) {
+ const ga = await createSession({ sub: "basic-admin", email: "admin@agentabrams.com", role: "ADMIN" });
+ const res = NextResponse.next();
+ res.cookies.set(SESSION_COOKIE, ga, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/",
+ maxAge: SESSION_MAX_AGE,
+ });
+ return res;
+ }
+
+ // Fleet single-sign-on: a valid shared *.agentabrams.com "aafleet" cookie
+ // establishes an ANALYST session here (operational — full data + imports +
+ // buyer pages), so one fleet login carries into auctions with no second form.
+ // The tier-0 credential surface still requires ADMIN, reached only via the
+ // local username/password login. Rationale: the aafleet token carries no
+ // per-user identity, audience, or role, so a leaked sibling-site cookie must
+ // not unlock this app's encrypted provider credentials or Stripe/billing
+ // state (DTD 2026-07-25, verdict B — scoped below ADMIN; bumped VIEWER→ANALYST
+ // per Steve so the fleet view is operational, not read-only).
+ // To fully sign out, log out at auth.agentabrams.com (clearing ga_session
+ // alone would just be re-minted from aafleet).
+ if (await fleetSsoOk(req.cookies.get(FLEET_SSO_COOKIE)?.value)) {
+ const ga = await createSession({ sub: "fleet-sso", email: "fleet@agentabrams.com", role: "ANALYST" });
+ const res = NextResponse.next();
+ res.cookies.set(SESSION_COOKIE, ga, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/",
+ maxAge: SESSION_MAX_AGE,
+ });
+ return res;
+ }
+
+ // API → 401 JSON; page → redirect to login with a return path.
+ if (pathname.startsWith("/api/")) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const url = req.nextUrl.clone();
+ url.pathname = "/login";
+ url.searchParams.set("next", pathname);
+ return NextResponse.redirect(url);
+}
+
+export const config = {
+ // Run on everything except Next internals + static asset files.
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|gif|ico|css|js|map|txt)$).*)"],
+};
diff --git a/src/pipeline/research.ts b/src/pipeline/research.ts
new file mode 100644
index 0000000..b541452
--- /dev/null
+++ b/src/pipeline/research.ts
@@ -0,0 +1,257 @@
+import { prisma } from "@/lib/db";
+import { identifyProduct } from "@/lib/ai";
+import { estimateDemand } from "@/engines/demand";
+import { computeValuation } from "@/engines/valuation";
+import { computeCosts } from "@/engines/costs";
+import { computeScores } from "@/engines/scoring";
+import { estimateFreight } from "@/engines/freight";
+import { clamp } from "@/lib/utils";
+import type { ConditionKey, SourceKey } from "@/engines/types";
+
+export interface ResearchOptions {
+ /** Run the local AI identifier (Ollama). Off by default for deterministic seeds. */
+ useAI?: boolean;
+ /** Deterministic overrides (used by the seeder to inject realistic anchors). */
+ anchor?: { newRetail?: number; demandScore?: number };
+ comparables?: {
+ kind: "SOLD" | "ACTIVE" | "RETAIL";
+ title: string;
+ price: number;
+ url?: string;
+ source?: string;
+ soldAt?: Date;
+ }[];
+ /** Persist AI-identified manufacturer/model back onto the listing. */
+ writeIdentity?: boolean;
+}
+
+/** Infer local-pickup requirement from weight + auction terms. */
+function inferLocalPickup(weightLbs: number, terms?: string | null): boolean {
+ const t = (terms || "").toLowerCase();
+ if (t.includes("pickup only") || t.includes("pick up only") || t.includes("no shipping")) return true;
+ if (t.includes("shipping available") || t.includes("will ship")) return false;
+ return weightLbs > 300; // heavy lots are usually pickup-only at gov auctions
+}
+
+/**
+ * Full research run for one listing: identify → value → cost → score → persist.
+ * Idempotent (upserts), safe to re-run. Returns a compact summary.
+ */
+export async function runResearch(listingId: string, opts: ResearchOptions = {}) {
+ const listing = await prisma.listing.findUnique({ where: { id: listingId } });
+ if (!listing) throw new Error(`Listing ${listingId} not found`);
+
+ await prisma.listing.update({
+ where: { id: listingId },
+ data: { researchStatus: "IN_PROGRESS" },
+ });
+
+ const text = [listing.title, listing.category, listing.description]
+ .filter(Boolean)
+ .join("\n");
+ const heuristic = estimateDemand(text);
+
+ // Identity + anchor resolution: overrides > AI > heuristic.
+ let manufacturer = listing.manufacturer;
+ let model = listing.model;
+ let category = listing.category;
+ let newRetail = opts.anchor?.newRetail ?? heuristic.retailFloor;
+ let demandScore = opts.anchor?.demandScore ?? heuristic.demandScore;
+ let identificationConfidence = 0.45; // heuristic baseline
+ let identifiedBy = "heuristic";
+ let identifyRaw: unknown = { heuristic };
+
+ if (opts.useAI) {
+ const { result, model: aiModel } = await identifyProduct({
+ title: listing.title,
+ description: listing.description,
+ category: listing.category,
+ });
+ if (result) {
+ manufacturer = result.manufacturer || manufacturer;
+ model = result.model || model;
+ category = result.category || category;
+ if (typeof result.estimatedNewRetail === "number" && result.estimatedNewRetail > 0) {
+ // Blend AI estimate with the heuristic floor to avoid wild outliers.
+ newRetail = Math.max(result.estimatedNewRetail, heuristic.retailFloor * 0.5);
+ }
+ if (typeof result.demandScore === "number") demandScore = clamp(result.demandScore, 0, 100);
+ identificationConfidence = clamp(result.confidence ?? 0.6, 0, 1);
+ identifiedBy = aiModel;
+ identifyRaw = result;
+ }
+ }
+
+ const condition = listing.condition as ConditionKey;
+ const comps = opts.comparables ?? [];
+
+ // --- Valuation ---
+ const valuation = computeValuation({
+ newRetail,
+ condition,
+ quantity: listing.quantity,
+ demandScore,
+ identificationConfidence,
+ comparableCount: comps.length,
+ });
+
+ // --- Costs (winning bid modeled as the current bid) ---
+ const weightLbs = listing.weightLbs ?? 0;
+ const localPickup = inferLocalPickup(weightLbs, listing.auctionTerms);
+ const fr = estimateFreight(weightLbs);
+ const costs = computeCosts({
+ source: listing.source as SourceKey,
+ winningBid: Number(listing.currentBid),
+ quantity: listing.quantity,
+ weightLbs,
+ condition,
+ expectedSalePrice: valuation.expectedSalePrice,
+ daysUntilSold: valuation.daysUntilSold,
+ localPickup,
+ });
+
+ // --- Scores ---
+ const scores = computeScores({
+ roi: costs.roi,
+ expectedNetProfit: costs.expectedNetProfit,
+ demandScore,
+ daysUntilSold: valuation.daysUntilSold,
+ weightLbs,
+ condition,
+ bidCount: listing.bidCount,
+ localPickup,
+ hasBuyerLeads: false,
+ confidenceScore: valuation.confidenceScore,
+ probabilityOfSale: valuation.probabilityOfSale,
+ isFreight: fr.isFreight,
+ });
+
+ // --- Persist ---
+ await prisma.$transaction(async (tx) => {
+ await tx.research.upsert({
+ where: { listingId },
+ create: {
+ listingId,
+ ...valuationToDb(valuation),
+ sources: comps.map((c) => ({ name: c.source ?? "comp", url: c.url, kind: c.kind, price: c.price })),
+ summary: buildSummary(listing.title, valuation, costs, demandScore),
+ model: identifiedBy,
+ },
+ update: {
+ ...valuationToDb(valuation),
+ summary: buildSummary(listing.title, valuation, costs, demandScore),
+ model: identifiedBy,
+ },
+ });
+
+ await tx.costBreakdown.upsert({
+ where: { listingId },
+ create: { listingId, ...costsToDb(costs) },
+ update: { ...costsToDb(costs) },
+ });
+
+ // Replace comparables.
+ await tx.comparable.deleteMany({ where: { listingId } });
+ if (comps.length) {
+ await tx.comparable.createMany({
+ data: comps.map((c) => ({
+ listingId,
+ kind: c.kind,
+ title: c.title,
+ price: c.price,
+ url: c.url,
+ source: c.source,
+ soldAt: c.soldAt,
+ })),
+ });
+ }
+
+ await tx.score.deleteMany({ where: { listingId } });
+ await tx.score.createMany({
+ data: scores.map((s) => ({
+ listingId,
+ profile: s.profile,
+ value: s.value,
+ arbitrage: s.components.arbitrage,
+ demand: s.components.demand,
+ velocity: s.components.velocity,
+ logistics: s.components.logistics,
+ condition: s.components.condition,
+ competition: s.components.competition,
+ buyer: s.components.buyer,
+ risk: s.risk,
+ dropShip: s.dropShip,
+ explanation: s.explanation,
+ factors: s.factors,
+ })),
+ });
+
+ await tx.listing.update({
+ where: { id: listingId },
+ data: {
+ researchStatus: "COMPLETE",
+ identifiedBy,
+ identifyRaw: identifyRaw as object,
+ ...(opts.writeIdentity ? { manufacturer, model, category } : {}),
+ },
+ });
+
+ await tx.listingEvent.create({
+ data: {
+ listingId,
+ type: "RESEARCH_COMPLETED",
+ message: `Research complete via ${identifiedBy}: est. net ${costs.expectedNetProfit.toFixed(0)}, ROI ${(costs.roi * 100).toFixed(0)}%`,
+ meta: { model: identifiedBy },
+ },
+ });
+ });
+
+ return {
+ listingId,
+ expectedNetProfit: costs.expectedNetProfit,
+ roi: costs.roi,
+ overallScore: scores.find((s) => s.profile === "OVERALL_OPPORTUNITY")?.value ?? 0,
+ identifiedBy,
+ };
+}
+
+function valuationToDb(v: ReturnType<typeof computeValuation>) {
+ return {
+ newRetail: v.newRetail,
+ newReplacement: v.newReplacement,
+ avgRetail: v.avgRetail,
+ usedSoldPrice: v.usedSoldPrice,
+ usedAskingPrice: v.usedAskingPrice,
+ usedLow: v.usedLow,
+ usedHigh: v.usedHigh,
+ wholesaleValue: v.wholesaleValue,
+ liquidationValue: v.liquidationValue,
+ sellTodayValue: v.sellTodayValue,
+ value7Day: v.value7Day,
+ value30Day: v.value30Day,
+ value90Day: v.value90Day,
+ expectedSalePrice: v.expectedSalePrice,
+ probabilityOfSale: v.probabilityOfSale,
+ daysUntilSold: v.daysUntilSold,
+ confidenceScore: v.confidenceScore,
+ };
+}
+
+function costsToDb(c: ReturnType<typeof computeCosts>) {
+ const { assumptions, ...rest } = c;
+ return { ...rest, assumptions: assumptions as object };
+}
+
+function buildSummary(
+ title: string,
+ v: ReturnType<typeof computeValuation>,
+ c: ReturnType<typeof computeCosts>,
+ demand: number,
+): string {
+ return (
+ `${title}: est. resale ${v.expectedSalePrice.toFixed(0)} vs new retail ${v.newRetail.toFixed(0)}. ` +
+ `At the current bid, total-in ${c.totalInvestment.toFixed(0)} → net ${c.expectedNetProfit.toFixed(0)} ` +
+ `(${(c.roi * 100).toFixed(0)}% ROI). Demand ${demand.toFixed(0)}/100, ~${v.daysUntilSold} days to sell, ` +
+ `confidence ${v.confidenceScore.toFixed(0)}/100. Recommended max bid ${c.recommendedMaxBid.toFixed(0)}.`
+ );
+}
diff --git a/src/worker/index.ts b/src/worker/index.ts
new file mode 100644
index 0000000..5162357
--- /dev/null
+++ b/src/worker/index.ts
@@ -0,0 +1,66 @@
+import { prisma } from "@/lib/db";
+import { runResearch } from "@/pipeline/research";
+import { ollamaReachable } from "@/lib/ai";
+
+// Research worker. Two modes:
+// • REDIS_URL set → BullMQ worker consuming a "research" queue.
+// • REDIS_URL unset → direct poll loop over PENDING listings (fine at this scale).
+
+const QUEUE = "research";
+
+async function processListing(listingId: string) {
+ await prisma.listing.update({ where: { id: listingId }, data: { researchStatus: "QUEUED" } });
+ try {
+ const r = await runResearch(listingId, { useAI: true, writeIdentity: true });
+ console.log(`✓ researched ${listingId} — net ${r.expectedNetProfit.toFixed(0)}, ROI ${(r.roi * 100).toFixed(0)}%`);
+ } catch (e) {
+ await prisma.listing.update({ where: { id: listingId }, data: { researchStatus: "FAILED" } });
+ await prisma.listingEvent.create({
+ data: { listingId, type: "RESEARCH_FAILED", message: (e as Error).message },
+ });
+ console.error(`✗ ${listingId}: ${(e as Error).message}`);
+ }
+}
+
+async function pendingIds(): Promise<string[]> {
+ const rows = await prisma.listing.findMany({
+ where: { researchStatus: { in: ["PENDING", "QUEUED"] } },
+ select: { id: true },
+ take: 25,
+ });
+ return rows.map((r) => r.id);
+}
+
+async function main() {
+ const local = await ollamaReachable();
+ console.log(`GovArbitrage worker starting. Local AI (Ollama) reachable: ${local}`);
+
+ if (process.env.REDIS_URL) {
+ // BullMQ mode (optional dependency).
+ const { Worker, Queue } = await import("bullmq");
+ const connection = { url: process.env.REDIS_URL };
+ const queue = new Queue(QUEUE, { connection });
+ // Enqueue any currently-pending listings.
+ for (const id of await pendingIds()) await queue.add("research", { listingId: id });
+ const worker = new Worker(
+ QUEUE,
+ async (job) => processListing(job.data.listingId as string),
+ { connection, concurrency: 2 },
+ );
+ worker.on("completed", (job) => console.log(`job ${job.id} done`));
+ console.log("BullMQ worker online.");
+ } else {
+ // Direct poll loop.
+ console.log("No REDIS_URL — running direct poll loop (Ctrl-C to stop).");
+ for (;;) {
+ const ids = await pendingIds();
+ for (const id of ids) await processListing(id);
+ await new Promise((r) => setTimeout(r, 5000));
+ }
+ }
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..362394b
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,44 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "ES2022"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "verbatimModuleSyntax": false,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ "apps",
+ "extension"
+ ]
+}
(oldest)
·
back to Govarbitrage
·
Deploy approved TK-11249 pricing copy; local source e69a5ea e3141d0 →