From 9c8f7fb6cd5122ef51f75b61b448673f093a9def Mon Sep 17 00:00:00 2001 From: Raufu Abdulraman Date: Fri, 3 Jul 2026 23:43:21 +0100 Subject: [PATCH] (feat): fully fucntioning complete landing and docs page --- frontend/.gitignore | 26 + frontend/README.md | 16 + frontend/eslint.config.js | 21 + frontend/index.html | 19 + frontend/package-lock.json | 2785 +++++++++++++++++ frontend/package.json | 31 + frontend/prompts/landing.md | 489 +++ frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.jsx | 83 + frontend/src/components/AmbientBackground.jsx | 18 + frontend/src/components/ApiPage.jsx | 52 + frontend/src/components/CodeBlock.jsx | 36 + frontend/src/components/CopyForLLM.jsx | 40 + frontend/src/components/DocsPage.jsx | 61 + frontend/src/components/Endpoint.jsx | 83 + frontend/src/components/Eyebrow.jsx | 11 + frontend/src/components/LedgerStrip.jsx | 32 + frontend/src/components/MethodBadge.jsx | 16 + frontend/src/components/ProfileMenu.jsx | 70 + frontend/src/components/Sidebar.jsx | 45 + frontend/src/components/SiteFooter.jsx | 88 + frontend/src/components/SiteNav.jsx | 68 + frontend/src/components/StatusPill.jsx | 16 + frontend/src/data/docsNav.js | 61 + frontend/src/index.css | 151 + frontend/src/layouts/DocsLayout.jsx | 31 + frontend/src/lib/api.js | 64 + frontend/src/lib/domToMarkdown.js | 78 + frontend/src/main.jsx | 13 + frontend/src/pages/ApiKeys.jsx | 180 ++ frontend/src/pages/Landing.jsx | 242 ++ frontend/src/pages/Login.jsx | 92 + frontend/src/pages/Signup.jsx | 107 + frontend/src/pages/docs/Authentication.jsx | 79 + frontend/src/pages/docs/Changelog.jsx | 35 + frontend/src/pages/docs/Customers.jsx | 75 + frontend/src/pages/docs/Errors.jsx | 58 + frontend/src/pages/docs/EventsWebhooks.jsx | 83 + frontend/src/pages/docs/FirstRequest.jsx | 40 + frontend/src/pages/docs/Idempotency.jsx | 60 + frontend/src/pages/docs/Introduction.jsx | 40 + frontend/src/pages/docs/Invoices.jsx | 76 + frontend/src/pages/docs/Lifecycle.jsx | 134 + frontend/src/pages/docs/Plans.jsx | 68 + frontend/src/pages/docs/Status.jsx | 28 + frontend/src/pages/docs/Subscriptions.jsx | 117 + frontend/src/pages/docs/api/ApiCustomers.jsx | 86 + frontend/src/pages/docs/api/ApiEvents.jsx | 48 + frontend/src/pages/docs/api/ApiInvoices.jsx | 55 + frontend/src/pages/docs/api/ApiPlans.jsx | 126 + .../src/pages/docs/api/ApiSubscriptions.jsx | 141 + frontend/src/pages/docs/api/ApiWebhooks.jsx | 27 + .../src/pages/docs/guides/FailedPayments.jsx | 61 + frontend/src/pages/docs/guides/Proration.jsx | 67 + frontend/src/pages/docs/guides/Recovery.jsx | 60 + .../pages/docs/guides/RecurringBilling.jsx | 66 + .../src/pages/docs/guides/VerifyWebhooks.jsx | 55 + frontend/vite.config.js | 8 + pxxl.toml | 6 - somba/api/app.py | 44 +- somba/api/auth.py | 199 ++ somba/api/middleware/auth.py | 21 +- somba/api/middleware/idempotency.py | 16 +- .../versions/0007_merchant_dashboard_auth.py | 58 + .../versions/0008_named_api_keys.py | 84 + somba/db/models.py | 49 +- somba/security.py | 56 + tests/conftest.py | 20 +- tests/unit/test_recovery_engine.py | 4 +- 70 files changed, 7337 insertions(+), 63 deletions(-) create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/prompts/landing.md create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/AmbientBackground.jsx create mode 100644 frontend/src/components/ApiPage.jsx create mode 100644 frontend/src/components/CodeBlock.jsx create mode 100644 frontend/src/components/CopyForLLM.jsx create mode 100644 frontend/src/components/DocsPage.jsx create mode 100644 frontend/src/components/Endpoint.jsx create mode 100644 frontend/src/components/Eyebrow.jsx create mode 100644 frontend/src/components/LedgerStrip.jsx create mode 100644 frontend/src/components/MethodBadge.jsx create mode 100644 frontend/src/components/ProfileMenu.jsx create mode 100644 frontend/src/components/Sidebar.jsx create mode 100644 frontend/src/components/SiteFooter.jsx create mode 100644 frontend/src/components/SiteNav.jsx create mode 100644 frontend/src/components/StatusPill.jsx create mode 100644 frontend/src/data/docsNav.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/layouts/DocsLayout.jsx create mode 100644 frontend/src/lib/api.js create mode 100644 frontend/src/lib/domToMarkdown.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/ApiKeys.jsx create mode 100644 frontend/src/pages/Landing.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Signup.jsx create mode 100644 frontend/src/pages/docs/Authentication.jsx create mode 100644 frontend/src/pages/docs/Changelog.jsx create mode 100644 frontend/src/pages/docs/Customers.jsx create mode 100644 frontend/src/pages/docs/Errors.jsx create mode 100644 frontend/src/pages/docs/EventsWebhooks.jsx create mode 100644 frontend/src/pages/docs/FirstRequest.jsx create mode 100644 frontend/src/pages/docs/Idempotency.jsx create mode 100644 frontend/src/pages/docs/Introduction.jsx create mode 100644 frontend/src/pages/docs/Invoices.jsx create mode 100644 frontend/src/pages/docs/Lifecycle.jsx create mode 100644 frontend/src/pages/docs/Plans.jsx create mode 100644 frontend/src/pages/docs/Status.jsx create mode 100644 frontend/src/pages/docs/Subscriptions.jsx create mode 100644 frontend/src/pages/docs/api/ApiCustomers.jsx create mode 100644 frontend/src/pages/docs/api/ApiEvents.jsx create mode 100644 frontend/src/pages/docs/api/ApiInvoices.jsx create mode 100644 frontend/src/pages/docs/api/ApiPlans.jsx create mode 100644 frontend/src/pages/docs/api/ApiSubscriptions.jsx create mode 100644 frontend/src/pages/docs/api/ApiWebhooks.jsx create mode 100644 frontend/src/pages/docs/guides/FailedPayments.jsx create mode 100644 frontend/src/pages/docs/guides/Proration.jsx create mode 100644 frontend/src/pages/docs/guides/Recovery.jsx create mode 100644 frontend/src/pages/docs/guides/RecurringBilling.jsx create mode 100644 frontend/src/pages/docs/guides/VerifyWebhooks.jsx create mode 100644 frontend/vite.config.js delete mode 100644 pxxl.toml create mode 100644 somba/api/auth.py create mode 100644 somba/db/migrations/versions/0007_merchant_dashboard_auth.py create mode 100644 somba/db/migrations/versions/0008_named_api_keys.py diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..89cd654 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +context \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a36934d --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ea36dd3 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d36f808 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + Somba — Recurring billing infrastructure for Nomba merchants + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a7289c2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2785 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.3.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-icons": "^5.7.0", + "react-router-dom": "^7.18.1", + "tailwindcss": "^4.3.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "vite": "^8.1.1" + } + }, + "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-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/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.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "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/@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==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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==", + "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/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "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==", + "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==", + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "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/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/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "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/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/baseline-browser-mapping": { + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "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.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.385", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", + "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==", + "dev": true, + "license": "ISC" + }, + "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==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@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", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.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", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "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-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "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/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/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-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/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/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/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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/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": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "ISC" + }, + "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/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/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-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/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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "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/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/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==", + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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/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/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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/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/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/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/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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.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-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/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/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/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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/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", + "optional": true + }, + "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/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": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/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/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": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "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/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..40cf154 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-icons": "^5.7.0", + "react-router-dom": "^7.18.1", + "tailwindcss": "^4.3.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "vite": "^8.1.1" + } +} diff --git a/frontend/prompts/landing.md b/frontend/prompts/landing.md new file mode 100644 index 0000000..e05ec4b --- /dev/null +++ b/frontend/prompts/landing.md @@ -0,0 +1,489 @@ +# Authkit — Style Reference +> Frosted glass cathedral at midnight + +**Theme:** keep current color theme. + +AuthKit renders a midnight product-launch aesthetic: a near-black canvas with frosted-glass surfaces, a grid of faint blueprint lines, and luminous text that appears lit from behind a glass layer. Type is almost entirely white-on-dark with one vivid violet as the single functional accent — every interactive surface wears a soft inset hairline of cool blue-white rather than a hard border. Components sit on translucent layers stacked above ambient glows, with cards that look like glass plates lit from below rather than paper panels. Spacing is generous and rhythmic; the hero is a single full-bleed illuminated wordmark surrounded by floating glass cards rather than a conventional split layout. + +## Tokens — Colors + +| Name | Value | Token | Role | +|------|-------|-------|------| +| Midnight Canvas | `#05060f` | `--color-midnight-canvas` | Page background, deepest card surface, badge fills — the near-black base everything else floats on | +| Steel Plate | `#2f343e` | `--color-steel-plate` | Elevated surface, button fills for ghost/secondary actions, subtle panel backing | +| Fog Veil | `#9da7ba` | `--color-fog-veil` | Muted body copy, card text — readable but stepped back from headlines | +| Moon Mist | `#c7d3ea` | `--color-moon-mist` | Body text, secondary labels, muted helper copy | +| Frost Glow | `#d1e4fa` | `--color-frost-glow` | Primary text fill for body and links, badge text, icon fills — the default luminous foreground | +| Ice Highlight | `linear-gradient(0deg, #d8ecf8 0%, #98c0ef 100%)` | `--color-ice-highlight` | Light text on dark surfaces, inverse labels, and high-contrast captions. Do not promote it to the primary CTA color; Headline gradient — top-to-bottom fade from Ice Highlight to soft blue, used on the AuthKit wordmark and key headings | +| Pure White | `#ffffff` | `--color-pure-white` | Button text, input text, maximum-emphasis foreground | +| Void Violet | `#663af3` | `--color-void-violet` | Primary CTA fill — the only chromatic accent, used exclusively for the Continue/Submit button inside auth forms; vivid violet against near-black creates focused urgency without breaking the monochromatic mood | +| Blueprint Blue | `#b6d9fc` | `--color-blueprint-blue` | Decorative icon accent, soft highlight wash on feature illustrations | +| Ember Glow | `#e46d4c` | `--color-ember-glow` | Secondary accent — appears in demo/showcase contexts (logo recoloring swatches) for brand-color customization display | +| Signal Blue | `#027dea` | `--color-signal-blue` | Secondary accent — appears in customization swatch grids to demonstrate brand-color options | +| Deep Teal | `#269684` | `--color-deep-teal` | Secondary accent — appears in customization swatch grids | +| Gridline Blue | `#3f4959` | `--color-gridline-blue` | Shadow color for outer card drop-shadows — cool dark blue-grey gives elevation a tinted, on-brand feel rather than neutral black | +| Glass Edge | `#bad7f71f` | `--color-glass-edge` | Hairline borders on buttons, inputs, and links — inset 1px stroke of frosted blue-white that defines edges without hard lines | +| Luminous Fill | `#c7d3ea1f` | `--color-luminous-fill` | Badge fill and soft surface tint — translucent cool white for tag backgrounds and subtle UI washes | + +## Tokens — Typography + +### Untitled Sans — Body, UI, buttons, inputs, badges, small headings — the working typeface for everything functional · `--font-untitled-sans` +- **Substitute:** Inter +- **Weights:** 400, 500, 600, 700 +- **Sizes:** 12px, 14px, 16px, 18px, 24px +- **Line height:** 1.17, 1.20, 1.33, 1.43, 1.50, 2.29, 2.57 +- **Letter spacing:** -0.0100em +- **Role:** Body, UI, buttons, inputs, badges, small headings — the working typeface for everything functional + +### aeonikPro — Display headings only — the wordmark 'AuthKit', section headings, hero copy; weight 500 at 44-48px gives the wordmark a wide, calm presence rather than a bold shout · `--font-aeonikpro` +- **Substitute:** Space Grotesk +- **Weights:** 400, 500 +- **Sizes:** 28px, 44px, 48px +- **Line height:** 1.14, 1.16, 1.17, 1.20 +- **Letter spacing:** normal +- **Role:** Display headings only — the wordmark 'AuthKit', section headings, hero copy; weight 500 at 44-48px gives the wordmark a wide, calm presence rather than a bold shout + +### dotDigital — All-caps eyebrow labels ('Introducing', 'Extensible by design', 'Shine bright') — 0.10em tracked monospace-flavored caps act as quiet section markers between the display type and body copy · `--font-dotdigital` +- **Substitute:** JetBrains Mono +- **Weights:** 400 +- **Sizes:** 15px +- **Line height:** 1.20 +- **Letter spacing:** 0.1000em +- **OpenType features:** `"tnum" on` +- **Role:** All-caps eyebrow labels ('Introducing', 'Extensible by design', 'Shine bright') — 0.10em tracked monospace-flavored caps act as quiet section markers between the display type and body copy + +### Type Scale + +| Role | Size | Line Height | Letter Spacing | Token | +|------|------|-------------|----------------|-------| +| caption | 12px | 1.33 | — | `--text-caption` | +| body-sm | 14px | 1.43 | — | `--text-body-sm` | +| body | 16px | 1.5 | -0.16px | `--text-body` | +| subheading | 18px | 1.33 | — | `--text-subheading` | +| heading-sm | 24px | 1.17 | -0.24px | `--text-heading-sm` | +| heading | 28px | 1.14 | — | `--text-heading` | +| heading-lg | 44px | 1.16 | — | `--text-heading-lg` | +| display | 48px | 1.17 | — | `--text-display` | + +## Tokens — Spacing & Shapes + +**Base unit:** 4px + +**Density:** comfortable + +### Spacing Scale + +| Name | Value | Token | +|------|-------|-------| +| 4 | 4px | `--spacing-4` | +| 8 | 8px | `--spacing-8` | +| 12 | 12px | `--spacing-12` | +| 16 | 16px | `--spacing-16` | +| 20 | 20px | `--spacing-20` | +| 24 | 24px | `--spacing-24` | +| 32 | 32px | `--spacing-32` | +| 36 | 36px | `--spacing-36` | +| 40 | 40px | `--spacing-40` | +| 48 | 48px | `--spacing-48` | +| 56 | 56px | `--spacing-56` | +| 100 | 100px | `--spacing-100` | +| 120 | 120px | `--spacing-120` | +| 200 | 200px | `--spacing-200` | + +### Border Radius + +| Element | Value | +|---------|-------| +| cards | 16px | +| badges | 6px | +| inputs | 6px | +| modals | 16px | +| buttons | 999px | +| iconContainers | 9999px | + +### Shadows + +| Name | Value | Token | +|------|-------|-------| +| sm | `rgba(186, 207, 247, 0.32) 0px 0px 6px 0px` | `--shadow-sm` | +| md | `rgba(238, 186, 247, 0.24) 0px 0px 12px 0px` | `--shadow-md` | +| subtle | `rgba(186, 215, 247, 0.12) 0px 0px 0px 1px inset` | `--shadow-subtle` | +| subtle-2 | `rgba(199, 211, 234, 0.12) -0.5px 0.5px 1px 0px inset, rgb...` | `--shadow-subtle-2` | +| subtle-3 | `rgba(186, 214, 247, 0.06) 0px 0px 0px 1px inset` | `--shadow-subtle-3` | +| subtle-4 | `rgba(199, 211, 234, 0.12) 0px 1px 1px 0px inset, rgba(199...` | `--shadow-subtle-4` | +| subtle-5 | `rgba(255, 255, 255, 0.1) 0px 0px 0px 1px inset` | `--shadow-subtle-5` | +| subtle-6 | `rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168,...` | `--shadow-subtle-6` | +| subtle-7 | `rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168,...` | `--shadow-subtle-7` | +| subtle-8 | `rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168,...` | `--shadow-subtle-8` | +| subtle-9 | `rgba(186, 214, 247, 0.24) 0px 0px 0px 1px inset` | `--shadow-subtle-9` | + +### Layout + +- **Page max-width:** 1200px +- **Section gap:** 120px +- **Card padding:** 24px +- **Element gap:** 16px + +## Components + +### Pill Button (Primary Ghost) +**Role:** Default button — used for 'Get started', 'Continue with Google/Microsoft', 'Learn more' links + +999px radius, padding 8px 16px, background rgba(186,214,247,0.06) (faint frost wash), text #ffffff, 1px inset border rgba(186,215,247,0.12) of frosted blue-white. Weight 500, 14px Untitled Sans. Hover lightens the frost wash to rgba(186,214,247,0.12). + +### Pill Button (Outlined) +**Role:** Secondary navigation button — header GitHub icon, secondary CTAs + +999px radius, padding 8px 16px, transparent background, text #d1e4fa, 1px inset border rgba(186,215,247,0.12). Same geometry as primary ghost; only the fill differs. + +### Violet CTA Button +**Role:** Sole chromatic CTA — appears only inside auth-form mockups as the 'Continue' submit button + +Solid fill #663af3, white text, 6px radius, padding 12px 24px, weight 500. The only place a non-monochrome button appears; its vivid violet punches against the midnight palette. + +### Glass Card (Feature) +**Role:** Feature cards, icon containers, section panels + +16px radius, background rgba(186,214,247,0.03) (nearly invisible frost tint), padding 24px, no hard border. Elevation built from inset frost highlight + soft outer halo — reads as a glass plate lit from behind. + +### Auth-Form Modal Card +**Role:** The headline product — floating login/signup cards in the hero + +16px radius, background rgba(5,6,15,0.97), padding 24-32px. Three-layer shadow stack: top inset frost (#d8ecf8 20%), mid inset glow (#a8d8f5 6%), bottom drop (#000 30%). Floats above the hero with the central card scaled larger than its siblings. + +### Text Input +**Role:** Email, password, and text fields inside auth forms + +6px radius, background rgba(199,211,234,0.06), text #ffffff, placeholder #c7d3ea at ~60% opacity, 1px inset border rgba(186,215,247,0.12). Padding 10px horizontal. Focus state increases the border opacity to 0.24. + +### Provider Button (Social Login) +**Role:** Continue with Google / Microsoft / SSO buttons + +Full-width pill (999px or 6px radius variant), padding 12px 16px, background rgba(199,211,234,0.06), white text, provider icon left-aligned. Divider 'OR' sits between email submit and social options in 12px muted caps. + +### Section Eyebrow Label +**Role:** All-caps section markers ('Introducing', 'Extensible by design', 'Shine bright', 'Light and dark modes supported') + +15px dotDigital, weight 400, letter-spacing 0.10em, color #c7d3ea, centered. Flanked by thin horizontal lines that fade from transparent to rgba(186,215,247,0.12) and back. + +### Feature Icon Tile +**Role:** Icon containers in the feature row (Single Sign-On, Password, MFA, Social Login, RBAC, Magic Auth) + +9999px radius (perfect circle), ~56-64px square, background frosted tint, outlined glyph icon in #d1e4fa. Icons are line-art (1.5px stroke), mono — no fill, no color variation between tiles. + +### Badge / Tag +**Role:** Category tags on integration cards (Email & Password, Social Login, MFA, SSO) + +6px radius, background rgba(199,211,234,0.12), text #d1e4fa, padding 4px 8px, 12px Untitled Sans weight 500. Multi-layer inset shadow gives a faint inner glow. + +### Logo Mark (WorkOS / AuthKit) +**Role:** Wordmark in header and hero + +WorkOS wordmark is Untitled Sans weight 500 at 16px in #d1e4fa. The AuthKit hero wordmark is aeonikPro weight 500 at ~140-180px (display size extrapolated), filled with the Skywash vertical gradient (#d8ecf8 → #98c0ef). + +### Background Grid Layer +**Role:** Ambient page atmosphere — blueprint grid behind all sections + +Full-bleed SVG/div layer with 1px lines at rgba(186,215,247,0.06), ~80-100px cell spacing, masked to fade at edges. A conic gradient halo sits at the top center creating a spotlight effect. + +### Theme Toggle (Light/Dark) +**Role:** Demonstrates the product's light/dark mode support + +Pill-shaped segmented control, 999px radius, two segments (moon icon / sun icon), 32px tall. Active segment has a slightly brighter frost background; inactive is transparent. + +### Customization Swatch +**Role:** Color picker tiles in the 'Your brand. Your style.' section + +Small 20-24px squares, 4-6px radius, filled with the brand color (violet, blue, teal, orange). Arranged in a row with 4px gaps. Labeled 'Colour' in 12px muted text. + +## Do's and Don'ts + +### Do +- Use 999px radius for all interactive elements (buttons, social-login buttons, tag toggles); reserve 16px radius exclusively for cards and modals, 6px for badges and inputs, and 9999px for circular icon containers. +- Build elevation from inset frost highlights + soft outer halos rather than conventional drop-shadows: pair inset rgba(216,236,248,0.2) 1px top edge with a 24-48px inset glow and a dark cool drop. +- Use Void Violet (#663af3) exclusively for the auth-form Continue/submit CTA — never as a decorative accent or non-auth button background. +- Set headline text in aeonikPro weight 500 at 44-48px with the Skywash vertical gradient (#d8ecf8 → #98c0ef); body and UI in Untitled Sans 400-500. +- Place all-caps eyebrow labels (dotDigital, 15px, 0.10em tracking, #c7d3ea) centered and flanked by fading horizontal lines at rgba(186,215,247,0.12) to mark every section opening. +- Use rgba(186,215,247,0.12) as the universal hairline border — never solid strokes; the frosted-inset edge is the system's border language. +- Set section gaps at 120px and card padding at 24px; rhythm should feel cathedral-like rather than dense SaaS. +- Render text in the Ice Highlight → Frost Glow → Moon Mist → Fog Veil progression (#d8ecf8 → #d1e4fa → #c7d3ea → #9da7ba) for heading → body → muted body → helper copy. +- Use the conic-gradient spotlight halo (rgba(124,145,182,0.5) at center, fading outward) at the top of every full-bleed hero to anchor the composition. + +### Don't +- Do not introduce additional chromatic accents — the palette is monochromatic with one violet CTA; any extra hue breaks the system. +- Do not use solid colored borders; replace them with 1px inset rgba(186,215,247,0.12) strokes to preserve the glass aesthetic. +- Do not use bold weights (600+) on aeonikPro display headings — the wordmark's authority comes from weight 500 at large size, not volume. +- Do not apply conventional drop-shadows; the system reads elevation through inset glow + dark halo. +- Do not mix radius families on the same component type — every button is pill, every card is 16px, every badge is 6px. +- Do not place white (#ffffff) on background tints brighter than rgba(186,214,247,0.12) — the contrast floor collapses. +- Do not use the Skywash gradient on body text or buttons; reserve it for the display wordmark and the largest headings only. +- Do not introduce light-theme colors into core tokens even though the product supports light mode; the marketing site is dark-first, and light-mode demos are a product feature, not a design-system palette. + +## Surfaces + +| Level | Name | Value | Purpose | +|-------|------|-------|---------| +| 0 | Midnight Canvas | `#05060f` | Full-bleed page background, deepest layer | +| 1 | Steel Plate | `#2f343` | Elevated panels, ghost-button fills | +| 2 | Frosted Glass | `#bad6f708` | Translucent card surface — barely-visible tint that reads as glass above the canvas | +| 3 | Deep Glass | `#05060ff7` | Auth-form modal surface — nearly opaque midnight with frosted-edge shadow stack | + +## Elevation + +- **Auth-form modal card:** `inset 0 1px 1px rgba(216, 236, 248, 0.2), inset 0 24px 48px rgba(168, 216, 245, 0.06), 0 16px 32px rgba(0, 0, 0, 0.3)` +- **Feature card:** `inset 0 1px 1px rgba(199, 211, 234, 0.12), inset 0 24px 48px rgba(199, 211, 234, 0.05), 0 24px 32px rgba(6, 6, 14, 0.7)` +- **Floating auth-card (hero):** `inset 0 1px 1px rgba(216, 236, 248, 0.2), inset 0 24px 48px rgba(168, 216, 245, 0.06), 0 16px 32px rgba(0, 0, 0, 0.3)` +- **Glow halo (behind hero wordmark):** `0 0 6px rgba(186, 207, 247, 0.32), 0 0 12px rgba(238, 186, 247, 0.24)` + +## Imagery + +Visuals are dominated by glass-morphism auth-form mockups (email/password inputs, social-login buttons, passwordless code-entry) rendered as floating translucent cards against the midnight canvas. Feature icons are line-art mono glyphs in #d1e4fa inside circular frosted tiles. A faint blueprint grid (1px lines at rgba(186,215,247,0.06)) covers the full page as ambient atmosphere, and a conic-gradient spotlight halo glows at the top of the hero. No photography, no lifestyle imagery, no product screenshots — the product IS the visual: login boxes arranged like glass prototypes in a dark studio. + +## Layout + +Full-bleed dark canvas, max-width 1200px content container centered. Hero is a single centered illuminated wordmark ('AuthKit' in gradient display type) under a small eyebrow label, with three floating glass auth-form cards layered behind/below in an overlapping fan (left card tilted left, center card scaled largest, right card tilted right). Below the hero, a light/dark theme toggle sits centered. Feature row is a horizontal 6-icon timeline with thin connecting lines between circular icon tiles. Section rhythm: every section opens with a centered eyebrow label flanked by fading horizontal lines, then a large centered heading (44-48px), then a single line of muted body copy (16-18px), max ~640px width. Customization section features a mock browser-window frame with the auth card centered, surrounded by floating UI inspector panels (color swatches, radius sliders, logo icon picker, button text field, page background field) positioned at the corners of the canvas like a design-tool workspace. + +## Agent Prompt Guide + +Quick Color Reference: +- canvas: #05060f +- surface (frosted glass card): rgba(186,214,247,0.03) +- surface (elevated modal): rgba(5,6,15,0.97) +- text (headline): #d8ecf8 +- text (body): #d1e4fa +- text (muted): #c7d3ea +- text (helper): #9da7ba +- border (hairline): rgba(186,215,247,0.12) +- accent / primary action: #663af3 (filled action) + +Example Component Prompts: + +1. Create a Primary Action Button: #663af3 background, #ffffff text, 9999px radius, compact pill padding. Use this filled treatment for the main CTA. + +2. Section eyebrow + heading stack: eyebrow is 15px dotDigital weight 400 letter-spacing 0.10em #c7d3ea, centered, flanked by fading horizontal lines (gradient from transparent to rgba(186,215,247,0.12) to transparent). Below, heading is 44px aeonikPro weight 500 in #d8ecf8, centered. Body below is 16px Untitled Sans 400 in #c7d3ea, max-width 640px centered. + +3. Feature icon tile row: six circular tiles (9999px radius, 56px), background rgba(186,214,247,0.06), outlined line-art icon centered in #d1e4fa, label below in 14px Untitled Sans #c7d3ea. Tiles connected by 1px horizontal line at rgba(186,215,247,0.12). + +4. Ghost pill button: 999px radius, padding 8px 16px, background rgba(186,214,247,0.06), 1px inset border rgba(186,215,247,0.12), text #ffffff, 14px Untitled Sans weight 500. + +5. Background canvas with grid: #05060f base, 1px grid lines at rgba(186,215,247,0.06) at 80px intervals, full-bleed, masked to fade at edges. Conic-gradient spotlight at top center: conic-gradient(at 50% -5%, transparent 45%, rgba(124,145,182,0.3) 49%, rgba(124,145,182,0.5) 50%, rgba(124,145,182,0.3) 51%, transparent 55%). + +## Gradient System + +The system uses three gradient layers stacked vertically: (1) Skywash linear gradient (#d8ecf8 → #98c0ef, 0deg) fills the display wordmark and largest headings; (2) Fading hairline gradients (transparent → rgba(186,215,247,0.12) → transparent) create the section divider lines flanking every eyebrow label; (3) Conic-gradient spotlight halos (transparent → rgba(124,145,182,0.5) → transparent) sit at the top of full-bleed sections as ambient illumination. All gradients are cool-tinted; never introduce warm gradients — the palette stays in the blue-violet spectrum. + +## Similar Brands + +- **Linear** — Same near-black canvas, monochromatic blue-white text, single vivid violet as the only chromatic accent, and floating glass-morphism product cards +- **Vercel** — Dark-first marketing surfaces with gradient-filled display type, frosted glass UI mockups, and minimal hairline borders at low opacity +- **Clerk** — Devtools auth-product landing with dark canvas, glass-card auth-form mockups as the hero visual, and monochrome-with-one-accent palette +- **Radix** — Companion brand — shares the WorkOS/Radix visual lineage with blueprint-grid backgrounds, frosted surfaces, and dot-tracked all-caps eyebrow labels +- **Stripe** — Gradient-filled display headings on dark backgrounds, translucent glass cards as product showcases, and restrained palette with one signature accent + +## Quick Start + +### CSS Custom Properties + +```css +:root { + /* Colors */ + --color-midnight-canvas: #05060f; + --color-steel-plate: #2f343e; + --color-fog-veil: #9da7ba; + --color-moon-mist: #c7d3ea; + --color-frost-glow: #d1e4fa; + --color-ice-highlight: #d8ecf8; + --gradient-ice-highlight: linear-gradient(0deg, #d8ecf8 0%, #98c0ef 100%); + --color-pure-white: #ffffff; + --color-void-violet: #663af3; + --color-blueprint-blue: #b6d9fc; + --color-ember-glow: #e46d4c; + --color-signal-blue: #027dea; + --color-deep-teal: #269684; + --color-gridline-blue: #3f4959; + --color-glass-edge: #bad7f71f; + --color-luminous-fill: #c7d3ea1f; + + /* Typography — Font Families */ + --font-untitled-sans: 'Untitled Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-aeonikpro: 'aeonikPro', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-dotdigital: 'dotDigital', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Typography — Scale */ + --text-caption: 12px; + --leading-caption: 1.33; + --text-body-sm: 14px; + --leading-body-sm: 1.43; + --text-body: 16px; + --leading-body: 1.5; + --tracking-body: -0.16px; + --text-subheading: 18px; + --leading-subheading: 1.33; + --text-heading-sm: 24px; + --leading-heading-sm: 1.17; + --tracking-heading-sm: -0.24px; + --text-heading: 28px; + --leading-heading: 1.14; + --text-heading-lg: 44px; + --leading-heading-lg: 1.16; + --text-display: 48px; + --leading-display: 1.17; + + /* Typography — Weights */ + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Spacing */ + --spacing-unit: 4px; + --spacing-4: 4px; + --spacing-8: 8px; + --spacing-12: 12px; + --spacing-16: 16px; + --spacing-20: 20px; + --spacing-24: 24px; + --spacing-32: 32px; + --spacing-36: 36px; + --spacing-40: 40px; + --spacing-48: 48px; + --spacing-56: 56px; + --spacing-100: 100px; + --spacing-120: 120px; + --spacing-200: 200px; + + /* Layout */ + --page-max-width: 1200px; + --section-gap: 120px; + --card-padding: 24px; + --element-gap: 16px; + + /* Border Radius */ + --radius-sm: 2px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-2xl: 16px; + --radius-3xl: 24px; + --radius-3xl-2: 28px; + --radius-3xl-3: 44px; + --radius-full: 999px; + --radius-full-2: 4999.5px; + --radius-full-3: 9999px; + + /* Named Radii */ + --radius-cards: 16px; + --radius-badges: 6px; + --radius-inputs: 6px; + --radius-modals: 16px; + --radius-buttons: 999px; + --radius-iconcontainers: 9999px; + + /* Shadows */ + --shadow-sm: rgba(186, 207, 247, 0.32) 0px 0px 6px 0px; + --shadow-md: rgba(238, 186, 247, 0.24) 0px 0px 12px 0px; + --shadow-subtle: rgba(186, 215, 247, 0.12) 0px 0px 0px 1px inset; + --shadow-subtle-2: rgba(199, 211, 234, 0.12) -0.5px 0.5px 1px 0px inset, rgba(186, 215, 247, 0.08) 0px 0px 96px 0px inset; + --shadow-subtle-3: rgba(186, 214, 247, 0.06) 0px 0px 0px 1px inset; + --shadow-subtle-4: rgba(199, 211, 234, 0.12) 0px 1px 1px 0px inset, rgba(199, 211, 234, 0.05) 0px 24px 48px 0px inset, rgba(6, 6, 14, 0.7) 0px 24px 32px 0px; + --shadow-subtle-5: rgba(255, 255, 255, 0.1) 0px 0px 0px 1px inset; + --shadow-subtle-6: rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168, 216, 245, 0.06) 0px 24px 48px 0px inset, rgba(0, 0, 0, 0.3) 0px 16px 32px 0px; + --shadow-subtle-7: rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168, 216, 245, 0.06) 0px 24px 48px 0px inset; + --shadow-subtle-8: rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168, 216, 245, 0.06) 0px 24px 48px 0px inset, rgba(199, 211, 234, 0.08) 0px 0px 0px 1px inset; + --shadow-subtle-9: rgba(186, 214, 247, 0.24) 0px 0px 0px 1px inset; + + /* Surfaces */ + --surface-midnight-canvas: #05060f; + --surface-steel-plate: #2f343; + --surface-frosted-glass: #bad6f708; + --surface-deep-glass: #05060ff7; +} +``` + +### Tailwind v4 + +```css +@theme { + /* Colors */ + --color-midnight-canvas: #05060f; + --color-steel-plate: #2f343e; + --color-fog-veil: #9da7ba; + --color-moon-mist: #c7d3ea; + --color-frost-glow: #d1e4fa; + --color-ice-highlight: #d8ecf8; + --color-pure-white: #ffffff; + --color-void-violet: #663af3; + --color-blueprint-blue: #b6d9fc; + --color-ember-glow: #e46d4c; + --color-signal-blue: #027dea; + --color-deep-teal: #269684; + --color-gridline-blue: #3f4959; + --color-glass-edge: #bad7f71f; + --color-luminous-fill: #c7d3ea1f; + + /* Typography */ + --font-untitled-sans: 'Untitled Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-aeonikpro: 'aeonikPro', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-dotdigital: 'dotDigital', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Typography — Scale */ + --text-caption: 12px; + --leading-caption: 1.33; + --text-body-sm: 14px; + --leading-body-sm: 1.43; + --text-body: 16px; + --leading-body: 1.5; + --tracking-body: -0.16px; + --text-subheading: 18px; + --leading-subheading: 1.33; + --text-heading-sm: 24px; + --leading-heading-sm: 1.17; + --tracking-heading-sm: -0.24px; + --text-heading: 28px; + --leading-heading: 1.14; + --text-heading-lg: 44px; + --leading-heading-lg: 1.16; + --text-display: 48px; + --leading-display: 1.17; + + /* Spacing */ + --spacing-4: 4px; + --spacing-8: 8px; + --spacing-12: 12px; + --spacing-16: 16px; + --spacing-20: 20px; + --spacing-24: 24px; + --spacing-32: 32px; + --spacing-36: 36px; + --spacing-40: 40px; + --spacing-48: 48px; + --spacing-56: 56px; + --spacing-100: 100px; + --spacing-120: 120px; + --spacing-200: 200px; + + /* Border Radius */ + --radius-sm: 2px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-2xl: 16px; + --radius-3xl: 24px; + --radius-3xl-2: 28px; + --radius-3xl-3: 44px; + --radius-full: 999px; + --radius-full-2: 4999.5px; + --radius-full-3: 9999px; + + /* Shadows */ + --shadow-sm: rgba(186, 207, 247, 0.32) 0px 0px 6px 0px; + --shadow-md: rgba(238, 186, 247, 0.24) 0px 0px 12px 0px; + --shadow-subtle: rgba(186, 215, 247, 0.12) 0px 0px 0px 1px inset; + --shadow-subtle-2: rgba(199, 211, 234, 0.12) -0.5px 0.5px 1px 0px inset, rgba(186, 215, 247, 0.08) 0px 0px 96px 0px inset; + --shadow-subtle-3: rgba(186, 214, 247, 0.06) 0px 0px 0px 1px inset; + --shadow-subtle-4: rgba(199, 211, 234, 0.12) 0px 1px 1px 0px inset, rgba(199, 211, 234, 0.05) 0px 24px 48px 0px inset, rgba(6, 6, 14, 0.7) 0px 24px 32px 0px; + --shadow-subtle-5: rgba(255, 255, 255, 0.1) 0px 0px 0px 1px inset; + --shadow-subtle-6: rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168, 216, 245, 0.06) 0px 24px 48px 0px inset, rgba(0, 0, 0, 0.3) 0px 16px 32px 0px; + --shadow-subtle-7: rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168, 216, 245, 0.06) 0px 24px 48px 0px inset; + --shadow-subtle-8: rgba(216, 236, 248, 0.2) 0px 1px 1px 0px inset, rgba(168, 216, 245, 0.06) 0px 24px 48px 0px inset, rgba(199, 211, 234, 0.08) 0px 0px 0px 1px inset; + --shadow-subtle-9: rgba(186, 214, 247, 0.24) 0px 0px 0px 1px inset; +} +``` diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..2b9429b --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,83 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import Landing from './pages/Landing' +import Signup from './pages/Signup' +import Login from './pages/Login' +import ApiKeys from './pages/ApiKeys' +import DocsLayout from './layouts/DocsLayout' + +import Introduction from './pages/docs/Introduction' +import Authentication from './pages/docs/Authentication' +import FirstRequest from './pages/docs/FirstRequest' +import Idempotency from './pages/docs/Idempotency' +import Errors from './pages/docs/Errors' + +import Plans from './pages/docs/Plans' +import Customers from './pages/docs/Customers' +import Subscriptions from './pages/docs/Subscriptions' +import Lifecycle from './pages/docs/Lifecycle' +import Invoices from './pages/docs/Invoices' +import EventsWebhooks from './pages/docs/EventsWebhooks' + +import RecurringBilling from './pages/docs/guides/RecurringBilling' +import FailedPayments from './pages/docs/guides/FailedPayments' +import Recovery from './pages/docs/guides/Recovery' +import Proration from './pages/docs/guides/Proration' +import VerifyWebhooks from './pages/docs/guides/VerifyWebhooks' + +import ApiPlans from './pages/docs/api/ApiPlans' +import ApiCustomers from './pages/docs/api/ApiCustomers' +import ApiSubscriptions from './pages/docs/api/ApiSubscriptions' +import ApiInvoices from './pages/docs/api/ApiInvoices' +import ApiEvents from './pages/docs/api/ApiEvents' +import ApiWebhooks from './pages/docs/api/ApiWebhooks' + +import Changelog from './pages/docs/Changelog' +import Status from './pages/docs/Status' + +export default function App() { + return ( + + } /> + } /> + } /> + } /> + + }> + } /> + + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + } /> + + + } /> + + ) +} diff --git a/frontend/src/components/AmbientBackground.jsx b/frontend/src/components/AmbientBackground.jsx new file mode 100644 index 0000000..fbf4d21 --- /dev/null +++ b/frontend/src/components/AmbientBackground.jsx @@ -0,0 +1,18 @@ +export default function AmbientBackground() { + return ( +
+
+
+
+ ) +} diff --git a/frontend/src/components/ApiPage.jsx b/frontend/src/components/ApiPage.jsx new file mode 100644 index 0000000..cbdb275 --- /dev/null +++ b/frontend/src/components/ApiPage.jsx @@ -0,0 +1,52 @@ +import { useRef } from 'react' +import { Link } from 'react-router-dom' +import { adjacentDocs } from '../data/docsNav' +import CopyForLLM from './CopyForLLM' + +export default function ApiPage({ eyebrow, title, description, path, children }) { + const { prev, next } = adjacentDocs(path) + const proseRef = useRef(null) + + return ( +
+
+ {eyebrow && ( +
+ {eyebrow} +
+ )} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} + + + +
+ {children} +
+ +
+ {prev ? ( + + ← {prev.title} + + ) : ( + + )} + {next ? ( + + {next.title} → + + ) : ( + + )} +
+
+
+ ) +} diff --git a/frontend/src/components/CodeBlock.jsx b/frontend/src/components/CodeBlock.jsx new file mode 100644 index 0000000..4b7baac --- /dev/null +++ b/frontend/src/components/CodeBlock.jsx @@ -0,0 +1,36 @@ +import { useState } from 'react' + +export default function CodeBlock({ title, lang, children }) { + const [copied, setCopied] = useState(false) + const code = typeof children === 'string' ? children.replace(/\n$/, '') : '' + + function onCopy() { + navigator.clipboard?.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + return ( +
+ {title && ( +
+ + {title} + + +
+ )} +
+        
+          {code}
+        
+      
+
+ ) +} diff --git a/frontend/src/components/CopyForLLM.jsx b/frontend/src/components/CopyForLLM.jsx new file mode 100644 index 0000000..d154937 --- /dev/null +++ b/frontend/src/components/CopyForLLM.jsx @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { articleToMarkdown } from '../lib/domToMarkdown' + +export default function CopyForLLM({ title, description, contentRef }) { + const [copied, setCopied] = useState(false) + + function buildMarkdown() { + const body = articleToMarkdown(contentRef.current) + return [`# ${title}`, description, body].filter(Boolean).join('\n\n') + } + + function buildPrompt() { + const url = typeof window !== 'undefined' ? window.location.href : '' + return `Read ${url} so I can ask you questions about the Somba API.` + } + + async function onCopy() { + await navigator.clipboard?.writeText(buildMarkdown()) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const prompt = encodeURIComponent(buildPrompt()) + const itemClass = + 'rounded-sm border border-line px-2.5 py-1 transition-colors hover:border-settled hover:text-settled' + + return ( +
+ + + open in chatgpt ↗ + + + open in claude ↗ + +
+ ) +} diff --git a/frontend/src/components/DocsPage.jsx b/frontend/src/components/DocsPage.jsx new file mode 100644 index 0000000..df4aabe --- /dev/null +++ b/frontend/src/components/DocsPage.jsx @@ -0,0 +1,61 @@ +import { useRef } from 'react' +import { Link } from 'react-router-dom' +import { adjacentDocs } from '../data/docsNav' +import CopyForLLM from './CopyForLLM' + +export default function DocsPage({ eyebrow, title, description, path, code, children }) { + const { prev, next } = adjacentDocs(path) + const proseRef = useRef(null) + + return ( +
+
+ {eyebrow && ( +
+ {eyebrow} +
+ )} +

+ {title} +

+ {description && ( +

{description}

+ )} + + + +
+ {children} +
+ +
+ {prev ? ( + + ← {prev.title} + + ) : ( + + )} + {next ? ( + + {next.title} → + + ) : ( + + )} +
+
+ + {code && ( + + )} +
+ ) +} diff --git a/frontend/src/components/Endpoint.jsx b/frontend/src/components/Endpoint.jsx new file mode 100644 index 0000000..8385b90 --- /dev/null +++ b/frontend/src/components/Endpoint.jsx @@ -0,0 +1,83 @@ +import MethodBadge from './MethodBadge' +import CodeBlock from './CodeBlock' + +function Field({ label, rows }) { + return ( +
+
+ {label} +
+
+ {rows.map((r) => ( +
+ {r.name} + + {r.type} + {r.required ? ', required' : r.type ? ', optional' : ''} + {r.note ? ` — ${r.note}` : ''} + +
+ ))} +
+
+ ) +} + +export default function Endpoint({ + id, + method, + path, + description, + headers = [], + body = [], + returns, + errors = [], + curl, + response, +}) { + return ( +
+
+
+
+ + {path} +
+

{description}

+ + {headers.length > 0 && } + {body.length > 0 && } + + {returns && ( +
+
+ Returns +
+

{returns}

+
+ )} + + {errors.length > 0 && ( +
+
+ Errors +
+
    + {errors.map((e) => ( +
  • + {e} +
  • + ))} +
+
+ )} +
+ +
+ {curl && {curl}} + {response && {response}} +
+
+
+ ) +} diff --git a/frontend/src/components/Eyebrow.jsx b/frontend/src/components/Eyebrow.jsx new file mode 100644 index 0000000..8a29252 --- /dev/null +++ b/frontend/src/components/Eyebrow.jsx @@ -0,0 +1,11 @@ +export default function Eyebrow({ children }) { + return ( +
+ + + {children} + + +
+ ) +} diff --git a/frontend/src/components/LedgerStrip.jsx b/frontend/src/components/LedgerStrip.jsx new file mode 100644 index 0000000..99203fa --- /dev/null +++ b/frontend/src/components/LedgerStrip.jsx @@ -0,0 +1,32 @@ +import StatusPill from './StatusPill' + +const rows = [ + { ref: 'evt_9f2a', event: 'charge.succeeded', amount: '₦4,500.00', kind: 'settled', label: 'settled' }, + { ref: 'evt_9f2b', event: 'charge.failed', amount: '₦12,000.00', kind: 'error', label: 'empty_account' }, + { ref: 'evt_9f2c', event: 'charge.recovered', amount: '₦12,000.00', kind: 'settled', label: 'timing' }, + { ref: 'evt_9f2d', event: 'payment.uncertain', amount: '₦2,300.00', kind: 'pending', label: 'verifying' }, +] + +export default function LedgerStrip() { + return ( +
+
+ ledger — live + every naira accounted for +
+
+ {rows.map((row) => ( +
+ {row.ref} + {row.event} + {row.label} + {row.amount} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/MethodBadge.jsx b/frontend/src/components/MethodBadge.jsx new file mode 100644 index 0000000..bf507bd --- /dev/null +++ b/frontend/src/components/MethodBadge.jsx @@ -0,0 +1,16 @@ +const styles = { + GET: 'text-settled border-settled/40 bg-settled-dim/40', + POST: 'text-pending border-pending/40 bg-pending-dim/40', + PATCH: 'text-pending border-pending/40 bg-pending-dim/40', + DELETE: 'text-error border-error/40 bg-error-dim/40', +} + +export default function MethodBadge({ method }) { + return ( + + {method} + + ) +} diff --git a/frontend/src/components/ProfileMenu.jsx b/frontend/src/components/ProfileMenu.jsx new file mode 100644 index 0000000..0947e53 --- /dev/null +++ b/frontend/src/components/ProfileMenu.jsx @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { fetchDashboardMe, getSessionToken, clearSessionToken } from '../lib/api' + +export default function ProfileMenu() { + const navigate = useNavigate() + const [merchant, setMerchant] = useState(null) + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const token = getSessionToken() + if (!token) return + fetchDashboardMe(token) + .then((data) => setMerchant(data.merchant)) + .catch(() => clearSessionToken()) + }, []) + + useEffect(() => { + function onClick(e) { + if (ref.current && !ref.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onClick) + return () => document.removeEventListener('mousedown', onClick) + }, []) + + if (!merchant) return null + + function onLogout() { + clearSessionToken() + setOpen(false) + navigate('/') + } + + const initial = merchant.name?.[0]?.toUpperCase() || '?' + + return ( +
+ + + {open && ( +
+
+ {merchant.email} +
+ setOpen(false)} + className="block px-4 py-2.5 font-mono text-[13px] text-text transition-colors hover:bg-panel-2" + > + API keys + + +
+ )} +
+ ) +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..382f5ef --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,45 @@ +import { NavLink } from 'react-router-dom' +import { docsNav } from '../data/docsNav' + +export default function Sidebar({ open = false, onClose }) { + return ( + <> + {open && ( +
+ )} + + + + ) +} diff --git a/frontend/src/components/SiteFooter.jsx b/frontend/src/components/SiteFooter.jsx new file mode 100644 index 0000000..127dbed --- /dev/null +++ b/frontend/src/components/SiteFooter.jsx @@ -0,0 +1,88 @@ +import { Link } from 'react-router-dom' +import { FaGithub } from 'react-icons/fa6' + +const columns = [ + { + heading: 'Product', + links: [ + { label: 'Introduction', to: '/docs/introduction' }, + { label: 'API reference', to: '/docs/api/plans' }, + { label: 'Guides', to: '/docs/guides/recurring-billing' }, + ], + }, + { + heading: 'Core concepts', + links: [ + { label: 'Plans', to: '/docs/plans' }, + { label: 'Subscriptions', to: '/docs/subscriptions' }, + { label: 'Events & webhooks', to: '/docs/events-webhooks' }, + ], + }, + { + heading: 'Resources', + links: [ + { label: 'Changelog', to: '/docs/changelog' }, + { label: 'Status', to: '/docs/status' }, + { label: 'Errors', to: '/docs/errors' }, + ], + }, + { + heading: 'Account', + links: [ + { label: 'Get API key', to: '/signup' }, + { label: 'Log in', to: '/login' }, + ], + }, +] + +export default function SiteFooter() { + return ( +
+
+
+
+
+ + somba +
+

+ Managed recurring billing for Nomba merchants — billing, recovery, and + reconciliation, handled. +

+ + + Watch on GitHub + +
+ + {columns.map((col) => ( +
+
+ {col.heading} +
+
    + {col.links.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ ))} +
+ +
+ Nomba × DevCareer Hackathon 2026. + Built on Nomba’s payment rails. +
+
+
+ ) +} diff --git a/frontend/src/components/SiteNav.jsx b/frontend/src/components/SiteNav.jsx new file mode 100644 index 0000000..c9bcb81 --- /dev/null +++ b/frontend/src/components/SiteNav.jsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { Link, NavLink } from 'react-router-dom' +import { FaBook, FaCode, FaGithub, FaKey } from 'react-icons/fa6' +import ProfileMenu from './ProfileMenu' +import { getSessionToken } from '../lib/api' + +const navLinkClass = ({ isActive }) => + `flex items-center gap-1.5 text-sm transition-colors ${ + isActive ? 'text-text' : 'text-text-muted hover:text-text' + }` + +export default function SiteNav() { + const [signedIn] = useState(() => Boolean(getSessionToken())) + + return ( +
+
+ + + somba + + + + + {signedIn ? ( + + ) : ( +
+ + Log in + + + + Get API key + +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/StatusPill.jsx b/frontend/src/components/StatusPill.jsx new file mode 100644 index 0000000..5116a99 --- /dev/null +++ b/frontend/src/components/StatusPill.jsx @@ -0,0 +1,16 @@ +const tone = { + settled: 'text-settled bg-settled-dim/50', + pending: 'text-pending bg-pending-dim/50', + error: 'text-error bg-error-dim/50', + neutral: 'text-text-muted bg-panel-2', +} + +export default function StatusPill({ children, kind = 'neutral' }) { + return ( + + {children} + + ) +} diff --git a/frontend/src/data/docsNav.js b/frontend/src/data/docsNav.js new file mode 100644 index 0000000..2078e69 --- /dev/null +++ b/frontend/src/data/docsNav.js @@ -0,0 +1,61 @@ +export const docsNav = [ + { + heading: 'Getting started', + items: [ + { title: 'Introduction', path: '/docs/introduction' }, + { title: 'Authentication', path: '/docs/authentication' }, + { title: 'Your first request', path: '/docs/first-request' }, + { title: 'Idempotency', path: '/docs/idempotency' }, + { title: 'Errors', path: '/docs/errors' }, + ], + }, + { + heading: 'Core concepts', + items: [ + { title: 'Plans', path: '/docs/plans' }, + { title: 'Customers', path: '/docs/customers' }, + { title: 'Subscriptions', path: '/docs/subscriptions' }, + { title: 'The subscription lifecycle', path: '/docs/lifecycle' }, + { title: 'Invoices', path: '/docs/invoices' }, + { title: 'Events & webhooks', path: '/docs/events-webhooks' }, + ], + }, + { + heading: 'Guides', + items: [ + { title: 'Set up recurring billing', path: '/docs/guides/recurring-billing' }, + { title: 'Handle failed payments', path: '/docs/guides/failed-payments' }, + { title: 'Understand recovery', path: '/docs/guides/recovery' }, + { title: 'Plan changes & proration', path: '/docs/guides/proration' }, + { title: 'Verify webhooks', path: '/docs/guides/verify-webhooks' }, + ], + }, + { + heading: 'API reference', + items: [ + { title: 'Plans', path: '/docs/api/plans' }, + { title: 'Customers', path: '/docs/api/customers' }, + { title: 'Subscriptions', path: '/docs/api/subscriptions' }, + { title: 'Invoices', path: '/docs/api/invoices' }, + { title: 'Events', path: '/docs/api/events' }, + { title: 'Webhooks (inbound)', path: '/docs/api/webhooks' }, + ], + }, + { + heading: 'Resources', + items: [ + { title: 'Changelog', path: '/docs/changelog' }, + { title: 'Status', path: '/docs/status' }, + ], + }, +] + +export const flatDocsNav = docsNav.flatMap((group) => group.items) + +export function adjacentDocs(path) { + const idx = flatDocsNav.findIndex((item) => item.path === path) + return { + prev: idx > 0 ? flatDocsNav[idx - 1] : null, + next: idx >= 0 && idx < flatDocsNav.length - 1 ? flatDocsNav[idx + 1] : null, + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..156e0b3 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,151 @@ +@import "tailwindcss"; + +@theme { + --color-ink: #0b0f0e; + --color-panel: #121815; + --color-panel-2: #171f1a; + --color-line: #232c27; + --color-line-soft: #1a2119; + --color-text: #edefea; + --color-text-muted: #8b948c; + --color-text-faint: #565f57; + --color-settled: #3ecf8e; + --color-settled-dim: #234a3a; + --color-pending: #e8a33d; + --color-pending-dim: #4a3a20; + --color-error: #e2574c; + --color-error-dim: #4a2622; + + --font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; +} + +@layer base { + * { + box-sizing: border-box; + } + + html { + background: var(--color-ink); + color-scheme: dark; + } + + body { + margin: 0; + background: var(--color-ink); + color: var(--color-text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + ::selection { + background: var(--color-settled-dim); + color: var(--color-settled); + } + + a { + color: inherit; + } + + :focus-visible { + outline: 2px solid var(--color-settled); + outline-offset: 2px; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: var(--color-line); + border-radius: 999px; + border: 2px solid var(--color-ink); + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } + } +} + +@layer components { + .doc-prose h2 { + margin-top: 0.5rem; + font-family: var(--font-mono); + font-size: 17px; + font-weight: 500; + color: var(--color-text); + letter-spacing: -0.01em; + } + + .doc-prose h3 { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + color: var(--color-text); + } + + .doc-prose p code, + .doc-prose li code { + font-family: var(--font-mono); + font-size: 0.85em; + color: var(--color-settled); + background: var(--color-panel); + border: 1px solid var(--color-line); + border-radius: 3px; + padding: 0.1em 0.4em; + } + + .doc-prose ul { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-left: 1.25rem; + list-style: disc; + } + + .doc-prose strong { + color: var(--color-text); + font-weight: 500; + } + + .doc-prose table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + + .doc-prose th { + border-bottom: 1px solid var(--color-line); + padding: 0.5rem 0.75rem; + text-align: left; + font-family: var(--font-mono); + font-weight: 500; + color: var(--color-text-faint); + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.03em; + } + + .doc-prose td { + border-bottom: 1px solid var(--color-line-soft); + padding: 0.6rem 0.75rem; + vertical-align: top; + color: var(--color-text-muted); + } + + .doc-prose td:first-child { + font-family: var(--font-mono); + color: var(--color-text); + white-space: nowrap; + } +} diff --git a/frontend/src/layouts/DocsLayout.jsx b/frontend/src/layouts/DocsLayout.jsx new file mode 100644 index 0000000..6880883 --- /dev/null +++ b/frontend/src/layouts/DocsLayout.jsx @@ -0,0 +1,31 @@ +import { useState } from 'react' +import { Outlet } from 'react-router-dom' +import { FaBars } from 'react-icons/fa6' +import SiteNav from '../components/SiteNav' +import Sidebar from '../components/Sidebar' + +export default function DocsLayout() { + const [mobileNavOpen, setMobileNavOpen] = useState(false) + + return ( +
+ + + + +
+ setMobileNavOpen(false)} /> +
+ +
+
+
+ ) +} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..1a57911 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,64 @@ +export const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' + +const SESSION_KEY = 'somba_session_token' + +async function parseJson(res) { + try { + return await res.json() + } catch { + return null + } +} + +async function request(path, { method = 'GET', body, auth } = {}) { + const headers = {} + if (body) headers['Content-Type'] = 'application/json' + if (auth) headers.Authorization = `Bearer ${auth}` + + const res = await fetch(`${API_BASE}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + const data = await parseJson(res) + if (!res.ok) { + throw new Error(data?.error?.message || 'Something went wrong. Is the API running?') + } + return data +} + +export function getSessionToken() { + return localStorage.getItem(SESSION_KEY) +} + +export function setSessionToken(token) { + localStorage.setItem(SESSION_KEY, token) +} + +export function clearSessionToken() { + localStorage.removeItem(SESSION_KEY) +} + +export function signup({ name, email, password }) { + return request('/v1/auth/signup', { method: 'POST', body: { name, email, password } }) +} + +export function login({ email, password }) { + return request('/v1/auth/login', { method: 'POST', body: { email, password } }) +} + +export function fetchDashboardMe(sessionToken) { + return request('/v1/auth/me', { auth: sessionToken }) +} + +export function listApiKeys(sessionToken) { + return request('/v1/auth/api-keys', { auth: sessionToken }) +} + +export function createApiKey(sessionToken, name) { + return request('/v1/auth/api-keys', { method: 'POST', auth: sessionToken, body: { name } }) +} + +export function revokeApiKey(sessionToken, keyRowId) { + return request(`/v1/auth/api-keys/${keyRowId}`, { method: 'DELETE', auth: sessionToken }) +} diff --git a/frontend/src/lib/domToMarkdown.js b/frontend/src/lib/domToMarkdown.js new file mode 100644 index 0000000..736a2ff --- /dev/null +++ b/frontend/src/lib/domToMarkdown.js @@ -0,0 +1,78 @@ +function inlineToMarkdown(el) { + let out = '' + el.childNodes.forEach((node) => { + if (node.nodeType === Node.TEXT_NODE) { + out += node.textContent + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + + const tag = node.tagName.toLowerCase() + if (tag === 'code') out += `\`${node.textContent}\`` + else if (tag === 'strong' || tag === 'b') out += `**${inlineToMarkdown(node)}**` + else if (tag === 'em' || tag === 'i') out += `_${inlineToMarkdown(node)}_` + else if (tag === 'a') out += `[${inlineToMarkdown(node)}](${node.getAttribute('href')})` + else out += inlineToMarkdown(node) + }) + return out +} + +function elementToMarkdown(el) { + return Array.from(el.childNodes) + .map(nodeToMarkdown) + .filter(Boolean) + .join('\n\n') +} + +function nodeToMarkdown(node) { + if (node.nodeType === Node.TEXT_NODE) return node.textContent.trim() + if (node.nodeType !== Node.ELEMENT_NODE) return '' + + if (node.hasAttribute('data-codeblock')) { + const titleEl = node.querySelector('[data-codeblock-title]') + const codeEl = node.querySelector('pre code') + const title = titleEl ? titleEl.textContent.trim() : '' + const code = codeEl ? codeEl.textContent : '' + return (title ? `**${title}**\n\n` : '') + '```\n' + code + '\n```' + } + + const tag = node.tagName.toLowerCase() + switch (tag) { + case 'h1': + return `# ${inlineToMarkdown(node)}` + case 'h2': + return `## ${inlineToMarkdown(node)}` + case 'h3': + return `### ${inlineToMarkdown(node)}` + case 'p': + return inlineToMarkdown(node) + case 'ul': + return Array.from(node.children) + .map((li) => `- ${inlineToMarkdown(li)}`) + .join('\n') + case 'pre': { + const codeEl = node.querySelector('code') + return '```\n' + (codeEl ? codeEl.textContent : node.textContent) + '\n```' + } + case 'table': { + const headRow = node.querySelector('thead tr') + const headers = headRow + ? Array.from(headRow.children).map((th) => th.textContent.trim()) + : [] + const rows = Array.from(node.querySelectorAll('tbody tr')).map((tr) => + Array.from(tr.children).map((td) => td.textContent.trim()), + ) + const headerLine = `| ${headers.join(' | ')} |` + const sepLine = `| ${headers.map(() => '---').join(' | ')} |` + const bodyLines = rows.map((r) => `| ${r.join(' | ')} |`) + return [headerLine, sepLine, ...bodyLines].join('\n') + } + default: + return elementToMarkdown(node) + } +} + +export function articleToMarkdown(el) { + if (!el) return '' + return elementToMarkdown(el).replace(/\n{3,}/g, '\n\n').trim() +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..2898346 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + + + , +) diff --git a/frontend/src/pages/ApiKeys.jsx b/frontend/src/pages/ApiKeys.jsx new file mode 100644 index 0000000..8aeb662 --- /dev/null +++ b/frontend/src/pages/ApiKeys.jsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import SiteNav from '../components/SiteNav' +import CodeBlock from '../components/CodeBlock' +import { + listApiKeys, + createApiKey, + revokeApiKey, + getSessionToken, + clearSessionToken, +} from '../lib/api' + +export default function ApiKeys() { + const navigate = useNavigate() + const [keys, setKeys] = useState(null) + const [name, setName] = useState('') + const [creating, setCreating] = useState(false) + const [revealedKey, setRevealedKey] = useState(null) + const [error, setError] = useState(null) + + function load() { + const token = getSessionToken() + if (!token) { + navigate('/login') + return + } + listApiKeys(token) + .then((data) => setKeys(data.api_keys)) + .catch(() => { + clearSessionToken() + navigate('/login') + }) + } + + useEffect(load, [navigate]) + + async function onCreate(e) { + e.preventDefault() + setError(null) + setCreating(true) + try { + const data = await createApiKey(getSessionToken(), name) + setRevealedKey(data.api_key) + setName('') + load() + } catch (err) { + setError(err.message) + } finally { + setCreating(false) + } + } + + async function onRevoke(id) { + setError(null) + try { + await revokeApiKey(getSessionToken(), id) + load() + } catch (err) { + setError(err.message) + } + } + + return ( +
+ + +
+
+
+ Account +
+

+ API keys +

+

+ Keys you mint here are what your own code uses to call the Somba API. Name them by + what they’re for — production, local development, a specific integration — + so revoking one is never a guess. +

+ + {revealedKey && ( +
+

+ Copy your API key now — it will never be shown again. +

+ {revealedKey} +
+ )} + +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + {keys === null && ( +

Loading your keys…

+ )} + + {keys?.length === 0 && ( +

+ No keys yet. Create one above to use in your code. +

+ )} + + {keys?.length > 0 && ( +
+ + + + + + + + + + + {keys.map((k) => ( + + + + + + + + ))} + +
+ Name + + Key + + Created + + Last used + +
{k.name} + sk-somba-{k.key_id}… + + {new Date(k.created_at).toLocaleDateString()} + + {k.last_used_at + ? new Date(k.last_used_at).toLocaleDateString() + : 'never'} + + +
+
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx new file mode 100644 index 0000000..bcab158 --- /dev/null +++ b/frontend/src/pages/Landing.jsx @@ -0,0 +1,242 @@ +import { Link } from 'react-router-dom' +import { FaArrowRotateRight, FaCalendarDays, FaGithub, FaScaleBalanced } from 'react-icons/fa6' +import SiteNav from '../components/SiteNav' +import SiteFooter from '../components/SiteFooter' +import CodeBlock from '../components/CodeBlock' +import LedgerStrip from '../components/LedgerStrip' +import AmbientBackground from '../components/AmbientBackground' +import Eyebrow from '../components/Eyebrow' + +const glass = + 'rounded-2xl border border-line/70 bg-panel/50 backdrop-blur-md shadow-[inset_0_1px_0_0_rgba(237,239,234,0.06),0_30px_60px_-20px_rgba(0,0,0,0.75)]' + +const features = [ + { + icon: FaCalendarDays, + title: 'Bills on schedule', + body: 'Define a plan. Subscribe a customer. Somba handles the rest automatically.', + }, + { + icon: FaArrowRotateRight, + title: 'Recovers failures', + body: 'Classifies every failed charge and routes it to the right recovery path automatically.', + }, + { + icon: FaScaleBalanced, + title: 'Proves every naira', + body: 'Full ledger of intents and settlements. Every charge accounted for.', + }, +] + +const steps = [ + { + n: '01', + title: 'Get your API key', + body: 'Create an account. Mint a key from your dashboard, shown once.', + }, + { + n: '02', + title: 'Create a plan and subscribe a customer', + body: 'POST /v1/plans then POST /v1/subscriptions. Somba starts billing on the cycle you set.', + }, + { + n: '03', + title: 'Listen to webhooks', + body: 'Somba signs every event with your webhook secret. React to charge.succeeded, charge.failed, subscription.past_due, and more.', + }, +] + +const curlExample = `curl -X POST https://somba.ddns.net/v1/subscriptions \\ + -H "Authorization: Bearer sk-somba-." \\ + -H "Idempotency-Key: sub-kemi-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "customer_id": "cus_xxx", + "plan_id": "plan_xxx" + }'` + +const webhookExample = `{ + "type": "charge.succeeded", + "data": { + "subscription_id": "sub_xxx", + "amount": 1500000 + } +}` + +const recoveredExample = `{ + "type": "charge.recovered", + "data": { + "subscription_id": "sub_xxx", + "recovery_path": "timing" + } +}` + +const recoveryPaths = [ + ['empty_account', 'Retry later, when funds are more likely present'], + ['broken_card', 'Stop pulling, switch to transfer fallback'], + ['transient', 'Retry once, then decide'], + ['risk', 'Stop — do not keep pushing'], +] + +export default function Landing() { + return ( +
+ + +
+
+ + +
+ Nomba × DevCareer Hackathon 2026 + +

+ Recurring billing infrastructure for Nomba merchants. +

+ +

+ Add subscriptions to your product in an afternoon. Somba handles the billing, + recovery, and reconciliation. You handle your product. +

+ +
+ + Read the docs + + + + View on GitHub + +
+
+ +
+
+ +
+
+ {curlExample} +
+
+ {webhookExample} +
+
+
+ +
+
+ What it does + +
+ {features.map((feature, i) => ( +
+
+
+ +
+

{feature.title}

+

{feature.body}

+
+ {i < features.length - 1 && ( + + )} +
+ ))} +
+
+
+ +
+
+ Integration +

+ Three steps to your first charge +

+
+ {steps.map((step) => ( +
+
{step.n}
+

{step.title}

+

{step.body}

+
+ ))} +
+
+
+ +
+
+ Recovery +
+
+

+ A failed charge isn’t a final answer. +

+

+ Somba classifies every failure and picks the next step itself — retry at a + better time, switch to transfer fallback, or stop if the payment looks unsafe. + You just listen for the webhook. +

+
+ {recoveryPaths.map(([code, meaning]) => ( +
+ {code} + {meaning} +
+ ))} +
+
+
+ {recoveredExample} +
+
+
+
+ +
+
+ Get started +

+ Add billing to your product this afternoon. +

+

+ Create an account, mint a key, and make your first request. No sales call required. +

+
+ + Create an account + + + Read the docs + +
+
+
+ +
+ + +
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..9302f66 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import SiteNav from '../components/SiteNav' +import { login, setSessionToken } from '../lib/api' + +export default function Login() { + const navigate = useNavigate() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [status, setStatus] = useState('idle') + const [error, setError] = useState(null) + + async function onSubmit(e) { + e.preventDefault() + setStatus('loading') + setError(null) + try { + const data = await login({ email, password }) + setSessionToken(data.session_token) + navigate('/docs/introduction') + } catch (err) { + setError(err.message) + setStatus('idle') + } + } + + return ( +
+ + +
+
+
+ Getting started +
+

+ Log in +

+

+ Get back into your dashboard to view or mint your API key. +

+ +
+ + + + + {error && ( +

+ {error} +

+ )} + + + +

+ Don’t have an account?{' '} + + Create one + +

+
+
+
+
+ ) +} diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx new file mode 100644 index 0000000..fd6698a --- /dev/null +++ b/frontend/src/pages/Signup.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import SiteNav from '../components/SiteNav' +import { signup, setSessionToken } from '../lib/api' + +export default function Signup() { + const navigate = useNavigate() + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [status, setStatus] = useState('idle') + const [error, setError] = useState(null) + + async function onSubmit(e) { + e.preventDefault() + setStatus('loading') + setError(null) + try { + const data = await signup({ name, email, password }) + setSessionToken(data.session_token) + navigate('/docs/introduction') + } catch (err) { + setError(err.message) + setStatus('idle') + } + } + + return ( +
+ + +
+
+
+ Getting started +
+

+ Create your account +

+

+ Your email and password get you into the dashboard. You mint your API key from there + — it’s a separate credential you use in your own code. +

+ +
+ + + + + + + {error && ( +

+ {error} +

+ )} + + + +

+ Already have an account?{' '} + + Log in + +

+
+
+
+
+ ) +} diff --git a/frontend/src/pages/docs/Authentication.jsx b/frontend/src/pages/docs/Authentication.jsx new file mode 100644 index 0000000..96c3c98 --- /dev/null +++ b/frontend/src/pages/docs/Authentication.jsx @@ -0,0 +1,79 @@ +import { Link } from 'react-router-dom' +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +export default function Authentication() { + return ( + +

+ You’ll need an API key before any of these requests will work.{' '} + + Click here + {' '} + to create an account and mint one. +

+ +

+ Your API key has two parts: a key_id, which Somba uses to look up your + merchant, and a secret, which is checked against the bcrypt hash Somba stores. Only the + hash is ever kept — Somba cannot show you the raw secret again after it’s issued. +

+ +

+ Pass the key on every request as a bearer token. It’s the only credential your code + needs — there’s no session to manage on the API side. The key itself is minted from + your dashboard, which you get into with your email and password. +

+ +
+

A request with a valid key

+ {`curl https://somba.ddns.net/v1/plans \\ + -H "Authorization: Bearer sk-somba-."`} + {`{ + "data": [ + { "id": "plan_xxx", "name": "Gym — Monthly", "status": "active" } + ] +}`} +
+ +

+ Don’t have a key yet?{' '} + + Create an account + {' '} + to get into your dashboard, or{' '} + + log in + {' '} + if you already have one, then mint a key from there. +

+ +

+ Your key is shown to you exactly once, when you mint it. If you lose it, generate a new + one from the dashboard — there is no recovery flow for a lost secret, by design. +

+ +
+

A missing or invalid key

+

+ A request with no bearer token is rejected with unauthorized. A request + with a token that doesn’t match any merchant is rejected with{' '} + invalid_api_key. Both happen before anything else runs. +

+ {`curl https://somba.ddns.net/v1/plans \\ + -H "Authorization: Bearer sk-somba-wrong"`} + {`{ + "error": { + "code": "invalid_api_key", + "message": "Invalid API key" + } +}`} +
+
+ ) +} diff --git a/frontend/src/pages/docs/Changelog.jsx b/frontend/src/pages/docs/Changelog.jsx new file mode 100644 index 0000000..34b0785 --- /dev/null +++ b/frontend/src/pages/docs/Changelog.jsx @@ -0,0 +1,35 @@ +import DocsPage from '../../components/DocsPage' + +const entries = [ + { + date: '2026-07-01', + title: 'Reconciliation sweep + smoke tests', + body: 'Added the periodic sweep job that resolves stuck payment_uncertain subscriptions and unmatched ledger intents. Added a golden-path smoke test covering plan/customer/subscription creation through a paid invoice.', + }, + { + date: '2026-06-18', + title: 'Proration on plan changes', + body: 'PATCH /v1/subscriptions/:id now calculates and returns a proration invoice for upgrades and downgrades.', + }, + { + date: '2026-06-02', + title: 'Transfer fallback recovery', + body: 'Failed charges classified as broken_card or risk now route to transfer fallback instead of continued retries. Added transfer.requested and transfer.reconciled events.', + }, +] + +export default function Changelog() { + return ( + +
+ {entries.map((e) => ( +
+
{e.date}
+

{e.title}

+

{e.body}

+
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/docs/Customers.jsx b/frontend/src/pages/docs/Customers.jsx new file mode 100644 index 0000000..297b3af --- /dev/null +++ b/frontend/src/pages/docs/Customers.jsx @@ -0,0 +1,75 @@ +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +const customer = `{ + "id": "cus_xxx", + "external_id": "user_8823", + "email": "kemi@example.com", + "name": "Kemi Adegoke", + "va_id": "va_xxx", + "va_account_no": "9012345678", + "credit_balance": 0 +}` + +export default function Customers() { + return ( + {customer}} + > +

+ A customer record ties a billing identity to your own user. It also holds the payment + token reference and, once assigned, a dedicated virtual account for transfer recovery. +

+ +

external_id

+

+ Set external_id to your own user ID at creation time. Every lookup — from a + webhook payload, from a support ticket, from your own dashboard — can then resolve back + to a customer by the identity your system already uses, without keeping a second mapping + table. +

+ +

The token key

+

+ Somba stores a reference to the customer’s payment method, never the raw card number. + Charges are made by asking Nomba to use the stored token — the card details themselves + never pass through or live in Somba. +

+ +

The virtual account

+

+ va_id and va_account_no are assigned automatically the first time + transfer fallback recovery fires for this customer. Before that happens, both fields are + empty. +

+ +
+

Creating a customer

+

+ Use external_id to store your own user ID, so you can always look a + customer up by the identity your system already knows. +

+ {`curl -X POST https://somba.ddns.net/v1/customers \\ + -H "Authorization: Bearer sk-somba-." \\ + -H "Idempotency-Key: cus-kemi-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "external_id": "user_8823", + "email": "kemi@example.com", + "name": "Kemi Adegoke" + }'`} + {`{ + "id": "cus_xxx", + "external_id": "user_8823", + "email": "kemi@example.com", + "name": "Kemi Adegoke", + "credit_balance": 0 +}`} +
+
+ ) +} diff --git a/frontend/src/pages/docs/Errors.jsx b/frontend/src/pages/docs/Errors.jsx new file mode 100644 index 0000000..20e052b --- /dev/null +++ b/frontend/src/pages/docs/Errors.jsx @@ -0,0 +1,58 @@ +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +const errorShape = `{ + "error": { + "code": "not_found", + "message": "Subscription not found" + } +}` + +const errors = [ + ['unauthorized', '401', 'Missing bearer token'], + ['invalid_api_key', '401', "Token doesn't match any merchant"], + ['missing_idempotency_key', '400', 'POST/PATCH/DELETE missing the header'], + ['idempotency_key_reuse', '409', 'Same key, different request body'], + ['not_found', '404', 'Plan, customer, subscription, invoice, or event not found'], + ['plan_archived', '400', "Can't subscribe or switch to an archived plan"], + ['already_archived', '400', 'Plan is already archived'], + ['invalid_status', '400', "Can't change plan on a subscription in this state"], + ['no_change', '400', 'Subscription is already on that plan'], +] + +export default function Errors() { + return ( + {errorShape}} + > +

+ code is machine-readable and safe to switch on. message is for + logs and support tickets. Some errors also include a param naming the field + they relate to. +

+ + + + + + + + + + + {errors.map(([code, http, meaning]) => ( + + + + + + ))} + +
CodeHTTPMeaning
{code}{http}{meaning}
+
+ ) +} diff --git a/frontend/src/pages/docs/EventsWebhooks.jsx b/frontend/src/pages/docs/EventsWebhooks.jsx new file mode 100644 index 0000000..a7c1892 --- /dev/null +++ b/frontend/src/pages/docs/EventsWebhooks.jsx @@ -0,0 +1,83 @@ +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +const verifyPython = `import hmac, hashlib + +def verify(payload: bytes, sig: str, secret: str) -> bool: + expected = hmac.new( + secret.encode(), payload, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, sig)` + +const eventList = `invoice.created +charge.succeeded +charge.failed # includes failure_reason and failure_class +charge.retrying +charge.recovered # includes recovery_path: timing | transfer +transfer.requested # VA number and amount included in payload +transfer.reconciled +subscription.past_due +subscription.active +subscription.paused +subscription.cancelled +payment.uncertain +payment.resolved +anomaly.detected` + +export default function EventsWebhooks() { + return ( + + {verifyPython} + {eventList} + + } + > +

+ Every important state change fires a webhook to the URL you configured, signed with your + webhook secret so you can confirm it actually came from Somba. +

+ +
+

What you receive

+

+ Once a charge succeeds, Somba posts a charge.succeeded event to your + webhook URL. That’s the moment to unlock access in your product. +

+ {`{ + "type": "charge.succeeded", + "data": { + "subscription_id": "sub_xxx", + "amount": 1500000, + "currency": "NGN" + } +}`} +
+ +

Verifying the signature

+

+ Compute an HMAC-SHA256 of the raw request body using your webhook secret, and compare it + against the signature Somba sends — using a constant-time comparison, never a plain{' '} + ==. +

+ +

Retries and dead letters

+

+ If your endpoint doesn’t return a 2xx, Somba retries the delivery on a backoff schedule. + After the retry schedule is exhausted, the delivery is marked dead-lettered rather than + retried forever. +

+ +

Replaying an event

+

+ If you missed a delivery — an endpoint was down, a deploy was mid-flight — you can ask + Somba to replay any past event by ID rather than trying to reconstruct state yourself. +

+
+ ) +} diff --git a/frontend/src/pages/docs/FirstRequest.jsx b/frontend/src/pages/docs/FirstRequest.jsx new file mode 100644 index 0000000..ea5987a --- /dev/null +++ b/frontend/src/pages/docs/FirstRequest.jsx @@ -0,0 +1,40 @@ +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +export default function FirstRequest() { + return ( + +
+

+ A plan defines the amount and cadence. Create one for a gym membership billed monthly + at ₦15,000.00 — amounts are always in kobo, so 1500000 kobo is ₦15,000.00. +

+ {`curl -X POST https://somba.ddns.net/v1/plans \\ + -H "Authorization: Bearer sk-somba-." \\ + -H "Idempotency-Key: plan-gym-monthly-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "name": "Gym — Monthly", + "amount": 1500000, + "currency": "NGN", + "interval": "month", + "interval_count": 1 + }'`} + {`{ + "id": "plan_xxx", + "name": "Gym — Monthly", + "amount": 1500000, + "currency": "NGN", + "interval": "month", + "interval_count": 1, + "status": "active" +}`} +
+
+ ) +} diff --git a/frontend/src/pages/docs/Idempotency.jsx b/frontend/src/pages/docs/Idempotency.jsx new file mode 100644 index 0000000..178700e --- /dev/null +++ b/frontend/src/pages/docs/Idempotency.jsx @@ -0,0 +1,60 @@ +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +const header = `Idempotency-Key: sub-kemi-2026-07-01` + +const missingKey = `{ + "error": { + "code": "missing_idempotency_key", + "message": "Mutating requests require an Idempotency-Key header", + "param": "Idempotency-Key" + } +}` + +const reuseKey = `{ + "error": { + "code": "idempotency_key_reuse", + "message": "Idempotency-Key was reused with a different request body", + "param": "Idempotency-Key" + } +}` + +export default function Idempotency() { + return ( + + {header} + {missingKey} + {reuseKey} + + } + > +

+ Every mutating request — POST, PATCH, or{' '} + DELETE — requires an Idempotency-Key header. If your + client retries a request because of a timeout or a dropped connection, Somba recognizes + the key and returns the original response instead of creating a second subscription, + invoice, or charge attempt. +

+ +

In plain English: doing the same action twice should have the same effect as doing it once.

+ +

+ Somba stores the request fingerprint tied to your merchant, so retries stay safe even + across process restarts on your side. +

+ +

Constructing a good key

+

+ A key should be unique per logical action, not per HTTP attempt. A pattern like{' '} + sub-{'{customer}'}-{'{date}'} works well — it’s stable across retries of + the same intent, but distinct from a genuinely new one you make later. +

+
+ ) +} diff --git a/frontend/src/pages/docs/Introduction.jsx b/frontend/src/pages/docs/Introduction.jsx new file mode 100644 index 0000000..0c8d59c --- /dev/null +++ b/frontend/src/pages/docs/Introduction.jsx @@ -0,0 +1,40 @@ +import { Link } from 'react-router-dom' +import DocsPage from '../../components/DocsPage' + +export default function Introduction() { + return ( + +

+ Somba sits between your product and Nomba’s payment rails. You tell it what plan a + customer is on and when the next bill should happen. Somba tracks the subscription + lifecycle, creates invoices, schedules charges, retries or reroutes failed payments, and + records what happened so it can be audited later. +

+ +

+ To use Somba you need a Nomba merchant account and a Somba API key. Every request is + scoped to your merchant — you will never see another merchant’s customers, plans, + or invoices, and they will never see yours. +

+ +

+ In return you get a plans and subscriptions API, an invoice record for every billing + period, webhooks for every state change, and a recovery engine that handles failed + payments without you writing retry logic. Start with{' '} + + authentication + + , then walk through{' '} + + your first request + + . +

+
+ ) +} diff --git a/frontend/src/pages/docs/Invoices.jsx b/frontend/src/pages/docs/Invoices.jsx new file mode 100644 index 0000000..d548d1d --- /dev/null +++ b/frontend/src/pages/docs/Invoices.jsx @@ -0,0 +1,76 @@ +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +const invoice = `{ + "id": "inv_xxx", + "subscription_id": "sub_xxx", + "customer_id": "cus_xxx", + "amount": 1500000, + "status": "paid", + "type": "recurring", + "period_start": "2026-07-01T00:00:00Z", + "period_end": "2026-07-31T23:59:59Z", + "due_date": "2026-07-01T00:00:00Z", + "paid_at": "2026-07-01T09:03:12Z" +}` + +const lineItems = `{ + "id": "inv_yyy", + "type": "proration", + "line_items": [ + { "type": "credit", "description": "Unused time on Basic", "amount": -420000 }, + { "type": "charge", "description": "Remaining time on Pro", "amount": 980000 } + ], + "amount": 560000 +}` + +export default function Invoices() { + return ( + + {invoice} + {lineItems} + + } + > +

+ Every billing period produces exactly one invoice for a subscription. Somba enforces + this with a uniqueness constraint on the subscription and period together, so a retried + billing run can never double-invoice the same period. +

+ +

Status flow

+

+ An invoice moves from draft to open once it’s finalized and ready to + be charged, then to paid once a charge settles against it — or to{' '} + uncollectible if recovery is exhausted without success. +

+ +

Line items

+

+ Regular recurring invoices don’t need a breakdown — the amount is the plan price. A + proration invoice does: it carries line items showing the credit from the old plan and + the charge for the new one, so the net amount is explainable rather than a single + opaque number. +

+ +
+

Fetching an invoice

+ {`curl https://somba.ddns.net/v1/invoices/inv_xxx \\ + -H "Authorization: Bearer sk-somba-."`} + {`{ + "id": "inv_xxx", + "status": "paid", + "amount": 1500000, + "period_start": "2026-07-01T00:00:00Z", + "period_end": "2026-07-31T23:59:59Z" +}`} +
+
+ ) +} diff --git a/frontend/src/pages/docs/Lifecycle.jsx b/frontend/src/pages/docs/Lifecycle.jsx new file mode 100644 index 0000000..ad9e27d --- /dev/null +++ b/frontend/src/pages/docs/Lifecycle.jsx @@ -0,0 +1,134 @@ +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +function StateBox({ children, kind = 'neutral' }) { + const tone = { + settled: 'border-settled/50 text-settled', + pending: 'border-pending/50 text-pending', + error: 'border-error/50 text-error', + neutral: 'border-line text-text-muted', + } + return ( + + {children} + + ) +} + +function FlowRow({ from, fromKind, label, to, toKind }) { + return ( +
+ {from} + — {label} → + {to} +
+ ) +} + +const transitions = [ + ['trialing', 'first successful charge', 'active', 'The trial converted into a paying subscription.'], + ['trialing', 'trial ends with no payment', 'expired', 'The trial finished and nothing renewed.'], + ['active', 'charge fails with recoverable reason', 'past_due', 'Somba gets a chance to recover the payment.'], + ['active', 'charge times out', 'payment_uncertain', 'The system cannot guess, so it freezes.'], + ['active', 'pause request', 'paused', 'The merchant or customer asked for a temporary stop.'], + ['active', 'cancel request', 'cancelled', 'The subscription was deliberately ended.'], + ['past_due', 'retry succeeds', 'active', 'The subscription has been healed.'], + ['past_due', 'transfer arrives and matches open invoice', 'active', 'The customer recovered by pushing money in.'], + ['payment_uncertain', 'verify confirms success', 'active', 'The missing result was actually successful.'], + ['payment_uncertain', 'verify confirms failure', 'past_due', 'The system now knows it needs recovery.'], + ['paused', 'resume request', 'active', 'Billing starts again.'], + ['cancelled', 'recreate new plan', 'trialing or active', 'A new subscription must be created deliberately.'], + ['expired', 'recreate new plan', 'trialing or active', 'A new subscription starts fresh.'], +] + +const gymSweep = `# periodic sweep, simplified +for sub in subscriptions.where(status="payment_uncertain"): + result = nomba.verify(sub.last_order_reference) + if result.succeeded: + sub.heal_to("active") + elif result.failed: + sub.transition_to("past_due")` + +export default function Lifecycle() { + return ( + {gymSweep}} + > +

The map

+
+
Happy path
+ + + +
Recovery
+ + + + + + +
Deliberate stops
+ + + +
+ +

+ Any transition not listed here is rejected outright. That’s deliberate — it prevents + accidental state changes that could create double billing or phantom access. +

+ +

Every legal transition

+ + + + + + + + + + + {transitions.map((row, i) => ( + + + + + + + ))} + +
CurrentEventNextWhy
{row[0]}{row[1]}{row[2]}{row[3]}
+ +

Three transitions worth understanding deeply

+ +

How a past_due subscription heals

+

+ Recovery is not just retries. A past_due subscription heals to active{' '} + either because a scheduled retry succeeded, or because a transfer arrived that matched + the open invoice. Both are treated as a genuine recovery, not a special case. +

+ +

What payment_uncertain means

+

+ It exists for one reason: a timeout is not the same thing as a failure. If Nomba hasn’t + confirmed the outcome yet, Somba doesn’t know whether money moved. Rather than risk a + double charge, the subscription freezes here until a verification pass settles the truth. + It never auto-retries in this state, because retrying blind is exactly the mistake it + exists to prevent. +

+ +

How a pushed transfer restores an active subscription

+

+ When a customer pushes money to their dedicated virtual account, Somba matches the + transfer against an open invoice by amount and reference. A good match heals the + subscription backward to active — the customer never has to contact support to + prove they paid. +

+
+ ) +} diff --git a/frontend/src/pages/docs/Plans.jsx b/frontend/src/pages/docs/Plans.jsx new file mode 100644 index 0000000..dacc0fc --- /dev/null +++ b/frontend/src/pages/docs/Plans.jsx @@ -0,0 +1,68 @@ +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' + +const plan = `{ + "id": "plan_xxx", + "name": "Gym — Bimonthly", + "amount": 2500000, + "currency": "NGN", + "interval": "month", + "interval_count": 2, + "trial_days": 7, + "status": "active" +}` + +export default function Plans() { + return ( + {plan}} + > +

+ A plan defines what a customer pays and how often. Subscriptions point to a plan; the + plan is where the amount and cadence actually live. +

+ +

interval and interval_count

+

+ Cadence is two fields, not a string to parse. “Every 2 months” is{' '} + interval: month with interval_count: 2. “Every year” is{' '} + interval: year with interval_count: 1. +

+ +

Active vs. archived

+

+ Archiving a plan does not touch existing subscriptions — they keep billing exactly + as before. It only blocks new subscriptions from being created against it. This is how + you retire a pricing tier without disrupting customers already on it. +

+ +
+

Creating a plan

+ {`curl -X POST https://somba.ddns.net/v1/plans \\ + -H "Authorization: Bearer sk-somba-." \\ + -H "Idempotency-Key: plan-gym-monthly-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "name": "Gym — Monthly", + "amount": 1500000, + "currency": "NGN", + "interval": "month", + "interval_count": 1 + }'`} + {`{ + "id": "plan_xxx", + "name": "Gym — Monthly", + "amount": 1500000, + "currency": "NGN", + "interval": "month", + "interval_count": 1, + "status": "active" +}`} +
+
+ ) +} diff --git a/frontend/src/pages/docs/Status.jsx b/frontend/src/pages/docs/Status.jsx new file mode 100644 index 0000000..8ae45fc --- /dev/null +++ b/frontend/src/pages/docs/Status.jsx @@ -0,0 +1,28 @@ +import DocsPage from '../../components/DocsPage' +import StatusPill from '../../components/StatusPill' + +const systems = [ + { name: 'API', status: 'Operational' }, + { name: 'Billing scheduler', status: 'Operational' }, + { name: 'Recovery engine', status: 'Operational' }, + { name: 'Webhook delivery', status: 'Operational' }, + { name: 'Reconciliation sweep', status: 'Operational' }, +] + +export default function Status() { + return ( + +
+ {systems.map((s) => ( +
+ {s.name} + {s.status} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/docs/Subscriptions.jsx b/frontend/src/pages/docs/Subscriptions.jsx new file mode 100644 index 0000000..cf7e19e --- /dev/null +++ b/frontend/src/pages/docs/Subscriptions.jsx @@ -0,0 +1,117 @@ +import { Link } from 'react-router-dom' +import DocsPage from '../../components/DocsPage' +import CodeBlock from '../../components/CodeBlock' +import StatusPill from '../../components/StatusPill' + +const subscription = `{ + "id": "sub_xxx", + "status": "active", + "customer_id": "cus_xxx", + "plan_id": "plan_xxx", + "current_period_start": "2026-07-01T00:00:00Z", + "current_period_end": "2026-07-31T23:59:59Z", + "next_bill_date": "2026-08-01T00:00:00Z", + "created_at": "2026-07-01T09:00:00Z" +}` + +const events = `subscription.active +subscription.past_due +subscription.paused +subscription.cancelled +payment.uncertain +payment.resolved` + +export default function Subscriptions() { + return ( + + {subscription} + {events} + + } + > +

+ A subscription is what you create when a customer commits to a plan. Somba tracks it + through seven possible states, only allows the transitions that make sense, and fires a + webhook every time the state changes. +

+ +

A gym membership, in plain English

+

+ A customer signs up for a monthly gym plan and starts on trialing. + Their first payment succeeds, and the membership becomes active. A + month later, a renewal fails because the account is empty — the membership moves to{' '} + past_due while Somba works on recovering it. Somba retries at a + better time and it heals back to active. Later, a renewal times + out with no clear result, so the membership freezes at{' '} + payment_uncertain rather than guessing. A verification pass + confirms the payment actually went through, and it heals back to{' '} + active again. +

+ +

+ The full state list and every legal transition between them are on{' '} + + the subscription lifecycle + {' '} + page. +

+ +

Grace period

+

+ A subscription in past_due is not immediately cut off. Somba gives it a grace + window while recovery is attempted, so a customer who is genuinely going to pay doesn’t + lose access over a bad morning. +

+ +

Heal-backward

+

+ Heal-backward means a subscription can move from a worse state back to a healthy one + without you doing anything. If a payment that looked failed or uncertain turns out to + have succeeded, the subscription heals back to active on its own — your + customer never needs to re-subscribe. +

+ +
+

Subscribing a customer

+

+ Subscribing starts the billing relationship. Somba schedules the first charge and every + renewal after it — you don’t need a cron job or a scheduler of your own. +

+ {`curl -X POST https://somba.ddns.net/v1/subscriptions \\ + -H "Authorization: Bearer sk-somba-." \\ + -H "Idempotency-Key: sub-kemi-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "customer_id": "cus_xxx", + "plan_id": "plan_xxx" + }'`} + {`{ + "id": "sub_xxx", + "status": "active", + "customer_id": "cus_xxx", + "plan_id": "plan_xxx", + "current_period_start": "2026-07-01T00:00:00Z", + "current_period_end": "2026-07-31T23:59:59Z", + "next_bill_date": "2026-08-01T00:00:00Z" +}`} +
+ +
+

Reading it back

+ {`curl https://somba.ddns.net/v1/subscriptions/sub_xxx \\ + -H "Authorization: Bearer sk-somba-."`} + {`{ + "id": "sub_xxx", + "status": "active", + "next_bill_date": "2026-08-01T00:00:00Z" +}`} +
+
+ ) +} diff --git a/frontend/src/pages/docs/api/ApiCustomers.jsx b/frontend/src/pages/docs/api/ApiCustomers.jsx new file mode 100644 index 0000000..ce2b1b0 --- /dev/null +++ b/frontend/src/pages/docs/api/ApiCustomers.jsx @@ -0,0 +1,86 @@ +import ApiPage from '../../../components/ApiPage' +import Endpoint from '../../../components/Endpoint' + +const auth = { name: 'Authorization', type: 'Bearer sk-somba-.' } +const idem = { name: 'Idempotency-Key', type: 'string', required: true } + +export default function ApiCustomers() { + return ( + + ." \\ + -H "Idempotency-Key: cus-kemi-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "external_id": "user_8823", + "email": "kemi@example.com", + "name": "Kemi Adegoke" + }'`} + response={`{ + "id": "cus_xxx", + "external_id": "user_8823", + "email": "kemi@example.com", + "name": "Kemi Adegoke", + "credit_balance": 0 +}`} + /> + + ."`} + response={`{ + "id": "cus_xxx", + "external_id": "user_8823", + "email": "kemi@example.com" +}`} + /> + + ." \\ + -H "Idempotency-Key: cus-update-001" \\ + -d '{ "email": "kemi.new@example.com" }'`} + response={`{ + "id": "cus_xxx", + "email": "kemi.new@example.com" +}`} + /> + + ) +} diff --git a/frontend/src/pages/docs/api/ApiEvents.jsx b/frontend/src/pages/docs/api/ApiEvents.jsx new file mode 100644 index 0000000..8384dce --- /dev/null +++ b/frontend/src/pages/docs/api/ApiEvents.jsx @@ -0,0 +1,48 @@ +import ApiPage from '../../../components/ApiPage' +import Endpoint from '../../../components/Endpoint' + +const auth = { name: 'Authorization', type: 'Bearer sk-somba-.' } +const idem = { name: 'Idempotency-Key', type: 'string', required: true } + +export default function ApiEvents() { + return ( + + ."`} + response={`{ + "data": [ + { "id": "evt_xxx", "type": "charge.failed", "created_at": "2026-07-01T09:00:00Z" } + ] +}`} + /> + + ." \\ + -H "Idempotency-Key: replay-evt-xxx-001"`} + response={`{ "id": "evt_xxx", "type": "charge.failed", "replayed": true }`} + /> + + ) +} diff --git a/frontend/src/pages/docs/api/ApiInvoices.jsx b/frontend/src/pages/docs/api/ApiInvoices.jsx new file mode 100644 index 0000000..a6b19cb --- /dev/null +++ b/frontend/src/pages/docs/api/ApiInvoices.jsx @@ -0,0 +1,55 @@ +import ApiPage from '../../../components/ApiPage' +import Endpoint from '../../../components/Endpoint' + +const auth = { name: 'Authorization', type: 'Bearer sk-somba-.' } + +export default function ApiInvoices() { + return ( + + ."`} + response={`{ + "data": [ + { "id": "inv_xxx", "status": "paid", "amount": 1500000 } + ] +}`} + /> + + ."`} + response={`{ + "id": "inv_xxx", + "status": "paid", + "amount": 1500000, + "period_start": "2026-07-01T00:00:00Z", + "period_end": "2026-07-31T23:59:59Z" +}`} + /> + + ) +} diff --git a/frontend/src/pages/docs/api/ApiPlans.jsx b/frontend/src/pages/docs/api/ApiPlans.jsx new file mode 100644 index 0000000..1950798 --- /dev/null +++ b/frontend/src/pages/docs/api/ApiPlans.jsx @@ -0,0 +1,126 @@ +import ApiPage from '../../../components/ApiPage' +import Endpoint from '../../../components/Endpoint' + +const auth = { name: 'Authorization', type: 'Bearer sk-somba-.' } +const idem = { name: 'Idempotency-Key', type: 'string', required: true } + +export default function ApiPlans() { + return ( + + ." \\ + -H "Idempotency-Key: plan-gym-monthly-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "name": "Gym — Monthly", + "amount": 1500000, + "currency": "NGN", + "interval": "month", + "interval_count": 1 + }'`} + response={`{ + "id": "plan_xxx", + "name": "Gym — Monthly", + "amount": 1500000, + "currency": "NGN", + "interval": "month", + "interval_count": 1, + "trial_days": 0, + "status": "active" +}`} + /> + + ."`} + response={`{ + "data": [ + { "id": "plan_xxx", "name": "Gym — Monthly", "status": "active" } + ] +}`} + /> + + ."`} + response={`{ + "id": "plan_xxx", + "name": "Gym — Monthly", + "status": "active" +}`} + /> + + ." \\ + -H "Idempotency-Key: plan-rename-001" \\ + -d '{ "name": "Gym — Monthly (2026)" }'`} + response={`{ + "id": "plan_xxx", + "name": "Gym — Monthly (2026)", + "status": "active" +}`} + /> + + ." \\ + -H "Idempotency-Key: plan-archive-001"`} + response={`{ + "id": "plan_xxx", + "status": "archived" +}`} + /> + + ) +} diff --git a/frontend/src/pages/docs/api/ApiSubscriptions.jsx b/frontend/src/pages/docs/api/ApiSubscriptions.jsx new file mode 100644 index 0000000..c41ff43 --- /dev/null +++ b/frontend/src/pages/docs/api/ApiSubscriptions.jsx @@ -0,0 +1,141 @@ +import ApiPage from '../../../components/ApiPage' +import Endpoint from '../../../components/Endpoint' + +const auth = { name: 'Authorization', type: 'Bearer sk-somba-.' } +const idem = { name: 'Idempotency-Key', type: 'string', required: true } + +const sub = `{ + "id": "sub_xxx", + "status": "active", + "customer_id": "cus_xxx", + "plan_id": "plan_xxx", + "current_period_start": "2026-07-01T00:00:00Z", + "current_period_end": "2026-07-31T23:59:59Z", + "next_bill_date": "2026-08-01T00:00:00Z", + "created_at": "2026-07-01T09:00:00Z" +}` + +export default function ApiSubscriptions() { + return ( + + ." \\ + -H "Idempotency-Key: sub-kemi-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "customer_id": "cus_xxx", + "plan_id": "plan_xxx" + }'`} + response={sub} + /> + + ."`} + response={sub} + /> + + ." \\ + -H "Idempotency-Key: upgrade-sub-xxx-001" \\ + -d '{ "plan_id": "plan_pro" }'`} + response={`{ + "id": "sub_xxx", + "status": "active", + "plan_id": "plan_pro", + "latest_invoice": { "id": "inv_yyy", "type": "proration", "amount": 560000 } +}`} + /> + + ." \\ + -H "Idempotency-Key: cancel-sub-xxx-001"`} + response={`{ "id": "sub_xxx", "status": "cancelled" }`} + /> + + ." \\ + -H "Idempotency-Key: pause-sub-xxx-001"`} + response={`{ "id": "sub_xxx", "status": "paused" }`} + /> + + ." \\ + -H "Idempotency-Key: resume-sub-xxx-001"`} + response={`{ "id": "sub_xxx", "status": "active" }`} + /> + + ." \\ + -H "Idempotency-Key: retry-sub-xxx-001"`} + response={`{ "id": "sub_xxx", "status": "past_due" }`} + /> + + ) +} diff --git a/frontend/src/pages/docs/api/ApiWebhooks.jsx b/frontend/src/pages/docs/api/ApiWebhooks.jsx new file mode 100644 index 0000000..e1c80d9 --- /dev/null +++ b/frontend/src/pages/docs/api/ApiWebhooks.jsx @@ -0,0 +1,27 @@ +import ApiPage from '../../../components/ApiPage' +import Endpoint from '../../../components/Endpoint' + +export default function ApiWebhooks() { + return ( + + + + ) +} diff --git a/frontend/src/pages/docs/guides/FailedPayments.jsx b/frontend/src/pages/docs/guides/FailedPayments.jsx new file mode 100644 index 0000000..6f15237 --- /dev/null +++ b/frontend/src/pages/docs/guides/FailedPayments.jsx @@ -0,0 +1,61 @@ +import DocsPage from '../../../components/DocsPage' +import CodeBlock from '../../../components/CodeBlock' + +const classes = `empty_account → retry later, better funding window +broken_card → stop pulling, switch to transfer fallback +transient → retry once, then decide +risk → stop, do not keep pushing +unknown → bounded retry, then fall back safely` + +export default function FailedPayments() { + return ( + {classes}} + > +

+ When a charge fails, Somba classifies the reason and picks the next step itself: retry + at a better time, switch to transfer fallback, or stop entirely if the payment looks + unsafe. Your job is to react to the webhooks, not to reimplement this logic. +

+ +

Timing recovery

+

+ The account was probably just empty. Somba schedules a retry for a more likely funding + window and sends charge.retrying. If it later succeeds, you get{' '} + charge.recovered with recovery_path: "timing" — update the + subscription status in your UI and move on. +

+ +

Transfer fallback

+

+ The card is dead or pulling no longer makes sense. Somba sends{' '} + transfer.requested with a dedicated virtual account number — show that to the + customer. Once the transfer is reconciled, you get charge.recovered with{' '} + recovery_path: "transfer". +

+ +

Fraud block

+

+ The payment looked unsafe. Somba does not retry. You’ll see the subscription move to{' '} + past_due without a scheduled recovery — treat this as a case for manual + review, not an automatic retry candidate. +

+ +
+

Asking for an immediate retry

+

+ If a customer tells you they’ve topped up, you can ask Somba to retry right away + instead of waiting for the scheduled window. +

+ {`curl -X POST https://somba.ddns.net/v1/subscriptions/sub_xxx/retry \\ + -H "Authorization: Bearer sk-somba-." \\ + -H "Idempotency-Key: retry-sub-xxx-001"`} + {`{ "id": "sub_xxx", "status": "past_due" }`} +
+
+ ) +} diff --git a/frontend/src/pages/docs/guides/Proration.jsx b/frontend/src/pages/docs/guides/Proration.jsx new file mode 100644 index 0000000..99bfb65 --- /dev/null +++ b/frontend/src/pages/docs/guides/Proration.jsx @@ -0,0 +1,67 @@ +import DocsPage from '../../../components/DocsPage' +import CodeBlock from '../../../components/CodeBlock' + +const patchCall = `curl -X PATCH https://somba.ddns.net/v1/subscriptions/sub_xxx \\ + -H "Authorization: Bearer sk-somba-." \\ + -H "Idempotency-Key: upgrade-sub-xxx-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "plan_id": "plan_pro" + }'` + +const patchResponse = `{ + "id": "sub_xxx", + "status": "active", + "plan_id": "plan_pro", + "latest_invoice": { + "id": "inv_yyy", + "type": "proration", + "amount": 560000, + "line_items": [ + { "type": "credit", "description": "Unused time on Basic", "amount": -420000 }, + { "type": "charge", "description": "Remaining time on Pro", "amount": 980000 } + ] + } +}` + +export default function Proration() { + return ( + + {patchCall} + {patchResponse} + + } + > +

+ Change a customer’s plan with a single PATCH call. Somba works out how much + value is left on the old plan, how much the new plan costs for the remaining days, and + charges only the difference. +

+ +
+ {patchCall} + {patchResponse} +
+ +

+ In the example, the customer had unused time on Basic worth ₦4,200.00. The remaining + days on Pro cost ₦9,800.00. Somba charges the net ₦5,600.00 immediately, and returns the + proration invoice with both line items so the amount is never a mystery to you or the + customer. +

+ +

Downgrades work in reverse

+

+ Downgrading stores the unused value as credit_balance on the customer instead + of refunding it. The next renewal checks that balance before charging — if it fully + covers the renewal, Somba doesn’t call Nomba for that cycle at all. +

+
+ ) +} diff --git a/frontend/src/pages/docs/guides/Recovery.jsx b/frontend/src/pages/docs/guides/Recovery.jsx new file mode 100644 index 0000000..cd2a27c --- /dev/null +++ b/frontend/src/pages/docs/guides/Recovery.jsx @@ -0,0 +1,60 @@ +import DocsPage from '../../../components/DocsPage' +import CodeBlock from '../../../components/CodeBlock' + +const why = `# Why not just retry on a second rail? +# If the account was empty on rail A, it's usually +# still empty on rail B — same customer, same balance. +# Somba prefers: wait for a better window, or ask for +# a transfer, over blind rerouting between pull rails.` + +export default function Recovery() { + return ( + {why}} + > +

+ Most payment tools stop at “charge failed.” Somba treats that as the start of a + second decision: is this worth retrying, and if so, when and how? +

+ +

Timing-based recovery

+

+ If a customer’s account was empty at 8 a.m., that’s not proof they’ll still be empty by + evening. Somba uses signals like expected payday and recent incoming transfers to retry + at a moment when the charge is actually likely to succeed, instead of hammering the same + card on a fixed interval. +

+ +

Transfer fallback

+

+ When pulling stops making sense — a dead card, a pattern of hard declines — Somba asks + the customer to push money to a dedicated virtual account instead. Transfers are + familiar and visible in Nigeria, and easy to reconcile automatically once they land. +

+ +

Why not just try a second pull rail

+

+ It sounds like an obvious next step, but it usually just reaches the same empty account + through a different door — more noise, more failed attempts, no better outcome. Somba’s + position is that timing plus transfer fallback solves the real problem more honestly than + rerouting between rails does. +

+ +
+

What a transfer request looks like

+ {`{ + "type": "transfer.requested", + "data": { + "subscription_id": "sub_xxx", + "va_account_no": "9012345678", + "amount": 1500000 + } +}`} +
+
+ ) +} diff --git a/frontend/src/pages/docs/guides/RecurringBilling.jsx b/frontend/src/pages/docs/guides/RecurringBilling.jsx new file mode 100644 index 0000000..ff1a479 --- /dev/null +++ b/frontend/src/pages/docs/guides/RecurringBilling.jsx @@ -0,0 +1,66 @@ +import DocsPage from '../../../components/DocsPage' +import CodeBlock from '../../../components/CodeBlock' + +const steps = `# 1. Create the plan +POST /v1/plans { name, amount, currency, interval, interval_count } + +# 2. Create the customer +POST /v1/customers { external_id, email, name } + +# 3. Subscribe them +POST /v1/subscriptions { customer_id, plan_id } + +# 4. Somba bills automatically on the cycle you set +# 5. You receive charge.succeeded on the first payment` + +export default function RecurringBilling() { + return ( + {steps}} + > +

+ Start by deciding your pricing shape and creating a plan for it. A plan is just an + amount and a cadence — you can create as many as you have pricing tiers. +

+ +

+ Next, create a customer record the moment someone signs up in your product. Set{' '} + external_id to the user ID you already have, so this record is always + reachable from your own system without a second lookup table. +

+ +

+ Subscribe the customer to the plan. This is the point where billing actually starts — + Somba calculates the first current_period_start and{' '} + current_period_end, and schedules the first charge. +

+ +
+ {`curl -X POST https://somba.ddns.net/v1/subscriptions \\ + -H "Authorization: Bearer sk-somba-." \\ + -H "Idempotency-Key: sub-kemi-001" \\ + -H "Content-Type: application/json" \\ + -d '{ + "customer_id": "cus_xxx", + "plan_id": "plan_xxx" + }'`} + {`{ + "id": "sub_xxx", + "status": "active", + "next_bill_date": "2026-08-01T00:00:00Z" +}`} +
+ +

+ From here you do nothing. Somba’s scheduler finds subscriptions due for billing, attempts + the charge, creates the invoice, and fires charge.succeeded or{' '} + charge.failed. Listen for the success event to grant access, and you have a + working recurring billing flow. +

+
+ ) +} diff --git a/frontend/src/pages/docs/guides/VerifyWebhooks.jsx b/frontend/src/pages/docs/guides/VerifyWebhooks.jsx new file mode 100644 index 0000000..f1f7cc7 --- /dev/null +++ b/frontend/src/pages/docs/guides/VerifyWebhooks.jsx @@ -0,0 +1,55 @@ +import DocsPage from '../../../components/DocsPage' +import CodeBlock from '../../../components/CodeBlock' + +const python = `import hmac, hashlib + +def verify(payload: bytes, sig: str, secret: str) -> bool: + expected = hmac.new( + secret.encode(), payload, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, sig)` + +const node = `const crypto = require("crypto"); + +function verify(payload, sig, secret) { + const expected = crypto + .createHmac("sha256", secret) + .update(payload) + .digest("hex"); + return crypto.timingSafeEqual( + Buffer.from(expected), + Buffer.from(sig) + ); +}` + +const curl = `# Recompute locally and diff against the +# X-Somba-Signature header — never trust an +# unsigned or unverified payload. +echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET"` + +export default function VerifyWebhooks() { + return ( + + {python} + {node} + {curl} + + } + > +

+ Recompute the HMAC-SHA256 of the raw request body using your webhook secret, and compare + it against the X-Somba-Signature header with a constant-time comparison. + Never process a payload whose signature doesn’t match. +

+ +
+ {python} +
+
+ ) +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..c4069b7 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], +}) diff --git a/pxxl.toml b/pxxl.toml deleted file mode 100644 index b3227f7..0000000 --- a/pxxl.toml +++ /dev/null @@ -1,6 +0,0 @@ -language = "python" -framework = "fastapi" -packageManager = "pip" -installCommand = "pip install -r requirements.txt" -startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}" -port = 8000 diff --git a/somba/api/app.py b/somba/api/app.py index 8f1576c..33ae430 100644 --- a/somba/api/app.py +++ b/somba/api/app.py @@ -4,11 +4,12 @@ from fastapi import Depends, FastAPI, Request from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session +from somba.api.auth import router as auth_router from somba.api.customers import router as customers_router from somba.api.errors import APIError, error_response from somba.api.events import router as events_router @@ -21,10 +22,16 @@ from somba.api.webhooks import router as webhooks_router from somba.db.models import Merchant from somba.db.session import get_db, init_db -from somba.security import generate_api_key_material app = FastAPI(title="Somba") app.add_middleware(IdempotencyMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(auth_router) app.include_router(webhooks_router) app.include_router(plans_router) app.include_router(customers_router) @@ -58,38 +65,6 @@ async def validation_error_handler(_: Request, exc: RequestValidationError) -> J ) -class MerchantCreateRequest(BaseModel): - name: str - webhook_url: str | None = None - webhook_secret: str = "" - - -@app.post("/v1/merchants", status_code=201) -def create_merchant( - body: MerchantCreateRequest, - db: Session = Depends(get_db), -) -> dict[str, object]: - key = generate_api_key_material() - merchant = Merchant( - name=body.name, - api_key_id=key.public_id, - api_key_hash=key.secret_hash, - webhook_url=body.webhook_url, - webhook_secret=body.webhook_secret, - ) - db.add(merchant) - db.commit() - db.refresh(merchant) - return { - "merchant": { - "id": merchant.id, - "name": merchant.name, - "webhook_url": merchant.webhook_url, - }, - "api_key": key.token, - } - - @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} @@ -101,7 +76,6 @@ def me(current_merchant: Merchant = Depends(get_current_merchant)) -> dict[str, "merchant": { "id": current_merchant.id, "name": current_merchant.name, - "api_key_id": current_merchant.api_key_id, "webhook_url": current_merchant.webhook_url, } } diff --git a/somba/api/auth.py b/somba/api/auth.py new file mode 100644 index 0000000..0df29c3 --- /dev/null +++ b/somba/api/auth.py @@ -0,0 +1,199 @@ +"""Dashboard authentication: email/password signup and login, session-scoped +merchant lookup, and minting the named API keys merchants use in their own +code. + +This is deliberately a separate credential from API keys. Email/password +gets a merchant into the dashboard; API keys are things they mint from +inside it and use in their own backend. Revoking one key, or ending a +session, never touches the others. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from somba.api.errors import APIError +from somba.db.models import ApiKey, Merchant, MerchantSession +from somba.db.session import get_db +from somba.security import ( + generate_api_key_material, + generate_session_token, + hash_password, + parse_session_token, + verify_api_key_secret, + verify_password, +) + +router = APIRouter(prefix="/v1/auth", tags=["auth"]) + + +def _merchant_to_dict(merchant: Merchant) -> dict: + return {"id": merchant.id, "name": merchant.name, "email": merchant.email} + + +def _api_key_to_dict(key: ApiKey) -> dict: + return { + "id": key.id, + "name": key.name, + "key_id": key.key_id, + "created_at": key.created_at.isoformat() if key.created_at else None, + "last_used_at": key.last_used_at.isoformat() if key.last_used_at else None, + } + + +def _issue_session(db: Session, merchant: Merchant) -> str: + token = generate_session_token() + db.add( + MerchantSession( + merchant_id=merchant.id, + session_id=token.session_id, + session_secret_hash=token.secret_hash, + ) + ) + db.commit() + return token.token + + +def get_current_dashboard_merchant( + request: Request, + db: Session = Depends(get_db), +) -> Merchant: + """Resolve the current merchant from a dashboard session bearer token.""" + + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + raise APIError(code="unauthorized", message="Missing session token", status_code=401) + token = header.removeprefix("Bearer ").strip() + + try: + session_id, secret = parse_session_token(token) + except ValueError as exc: + raise APIError(code="invalid_session", message=str(exc), status_code=401) from exc + + session = db.scalar( + select(MerchantSession).where(MerchantSession.session_id == session_id) + ) + if session is None or not verify_api_key_secret(secret, session.session_secret_hash): + raise APIError(code="invalid_session", message="Invalid or expired session", status_code=401) + + merchant = db.get(Merchant, session.merchant_id) + if merchant is None: + raise APIError(code="invalid_session", message="Invalid or expired session", status_code=401) + return merchant + + +def _get_api_key_or_404(db: Session, key_row_id: int, merchant: Merchant) -> ApiKey: + key = db.scalar( + select(ApiKey).where( + ApiKey.id == key_row_id, + ApiKey.merchant_id == merchant.id, + ApiKey.revoked_at.is_(None), + ) + ) + if key is None: + raise APIError(code="not_found", message="API key not found", status_code=404) + return key + + +class SignupRequest(BaseModel): + name: str = Field(max_length=255) + email: EmailStr + password: str = Field(min_length=8, max_length=255) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class ApiKeyCreateRequest(BaseModel): + name: str = Field(max_length=255) + + +@router.post("/signup", status_code=201) +def signup(body: SignupRequest, db: Session = Depends(get_db)) -> dict: + existing = db.scalar(select(Merchant).where(Merchant.email == body.email)) + if existing is not None: + raise APIError(code="email_taken", message="An account with this email already exists", status_code=409) + + merchant = Merchant( + name=body.name, + email=body.email, + password_hash=hash_password(body.password), + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + + session_token = _issue_session(db, merchant) + return {"merchant": _merchant_to_dict(merchant), "session_token": session_token} + + +@router.post("/login") +def login(body: LoginRequest, db: Session = Depends(get_db)) -> dict: + merchant = db.scalar(select(Merchant).where(Merchant.email == body.email)) + if merchant is None or merchant.password_hash is None or not verify_password( + body.password, merchant.password_hash + ): + raise APIError(code="invalid_credentials", message="Invalid email or password", status_code=401) + + session_token = _issue_session(db, merchant) + return {"merchant": _merchant_to_dict(merchant), "session_token": session_token} + + +@router.get("/me") +def me(merchant: Merchant = Depends(get_current_dashboard_merchant)) -> dict: + return {"merchant": _merchant_to_dict(merchant)} + + +@router.get("/api-keys") +def list_api_keys( + db: Session = Depends(get_db), + merchant: Merchant = Depends(get_current_dashboard_merchant), +) -> dict: + keys = list( + db.scalars( + select(ApiKey) + .where(ApiKey.merchant_id == merchant.id, ApiKey.revoked_at.is_(None)) + .order_by(ApiKey.created_at.desc()) + ) + ) + return {"api_keys": [_api_key_to_dict(k) for k in keys]} + + +@router.post("/api-keys", status_code=201) +def create_api_key( + body: ApiKeyCreateRequest, + db: Session = Depends(get_db), + merchant: Merchant = Depends(get_current_dashboard_merchant), +) -> dict: + """Mint a new named API key for the merchant. Existing keys are untouched.""" + + material = generate_api_key_material() + key = ApiKey( + merchant_id=merchant.id, + name=body.name, + key_id=material.public_id, + key_hash=material.secret_hash, + ) + db.add(key) + db.commit() + db.refresh(key) + return {**_api_key_to_dict(key), "api_key": material.token} + + +@router.delete("/api-keys/{key_row_id}") +def revoke_api_key( + key_row_id: int, + db: Session = Depends(get_db), + merchant: Merchant = Depends(get_current_dashboard_merchant), +) -> dict: + key = _get_api_key_or_404(db, key_row_id, merchant) + key.revoked_at = datetime.now(timezone.utc) + db.commit() + return {"id": key.id, "revoked": True} diff --git a/somba/api/middleware/auth.py b/somba/api/middleware/auth.py index 95dea84..41cd982 100644 --- a/somba/api/middleware/auth.py +++ b/somba/api/middleware/auth.py @@ -2,12 +2,14 @@ from __future__ import annotations +from datetime import datetime, timezone + from fastapi import Depends, Request from sqlalchemy import select from sqlalchemy.orm import Session from somba.api.errors import APIError -from somba.db.models import Merchant +from somba.db.models import ApiKey, Merchant from somba.db.session import get_db from somba.security import parse_api_key, verify_api_key_secret @@ -42,11 +44,24 @@ def get_current_merchant( status_code=401, ) from exc - merchant = db.scalar(select(Merchant).where(Merchant.api_key_id == public_id)) - if merchant is None or not verify_api_key_secret(secret, merchant.api_key_hash): + api_key = db.scalar( + select(ApiKey).where(ApiKey.key_id == public_id, ApiKey.revoked_at.is_(None)) + ) + if api_key is None or not verify_api_key_secret(secret, api_key.key_hash): + raise APIError( + code="invalid_api_key", + message="Invalid API key", + status_code=401, + ) + + merchant = db.get(Merchant, api_key.merchant_id) + if merchant is None: raise APIError( code="invalid_api_key", message="Invalid API key", status_code=401, ) + + api_key.last_used_at = datetime.now(timezone.utc) + db.commit() return merchant diff --git a/somba/api/middleware/idempotency.py b/somba/api/middleware/idempotency.py index 2c54a78..4f0bdd5 100644 --- a/somba/api/middleware/idempotency.py +++ b/somba/api/middleware/idempotency.py @@ -24,7 +24,7 @@ from starlette.responses import Response from somba.api.errors import APIError, error_response -from somba.db.models import IdempotencyRecord, IdempotencyRecordStatus, Merchant +from somba.db.models import ApiKey, IdempotencyRecord, IdempotencyRecordStatus from somba.db.session import get_db from somba.security import parse_api_key, verify_api_key_secret @@ -32,13 +32,17 @@ MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} IDEMPOTENCY_EXEMPT = {"/v1/webhooks/nomba"} +IDEMPOTENCY_EXEMPT_PREFIXES = ("/v1/auth/",) class IdempotencyMiddleware(BaseHTTPMiddleware): """Require an idempotency key and replay stored responses on repeat keys.""" async def dispatch(self, request: Request, call_next): - if request.method not in MUTATING_METHODS or request.url.path in IDEMPOTENCY_EXEMPT: + exempt = request.url.path in IDEMPOTENCY_EXEMPT or request.url.path.startswith( + IDEMPOTENCY_EXEMPT_PREFIXES + ) + if request.method not in MUTATING_METHODS or exempt: return await call_next(request) key = request.headers.get("Idempotency-Key", "").strip() @@ -179,10 +183,12 @@ def _resolve_merchant_id(self, request: Request) -> int | None: return None db, gen = self._session(request) try: - merchant = db.scalar(select(Merchant).where(Merchant.api_key_id == public_id)) - if merchant is None or not verify_api_key_secret(secret, merchant.api_key_hash): + api_key = db.scalar( + select(ApiKey).where(ApiKey.key_id == public_id, ApiKey.revoked_at.is_(None)) + ) + if api_key is None or not verify_api_key_secret(secret, api_key.key_hash): return None - return merchant.id + return api_key.merchant_id finally: self._close(gen) diff --git a/somba/db/migrations/versions/0007_merchant_dashboard_auth.py b/somba/db/migrations/versions/0007_merchant_dashboard_auth.py new file mode 100644 index 0000000..f6aac3d --- /dev/null +++ b/somba/db/migrations/versions/0007_merchant_dashboard_auth.py @@ -0,0 +1,58 @@ +"""Add dashboard email/password auth, separate from the API key credential. + +Merchants now sign up with name/email/password and mint an API key later from +the dashboard, instead of getting one immediately at account creation. Adds +merchants.email/password_hash, makes api_key_id/api_key_hash nullable (no key +until minted), and adds merchant_sessions for dashboard login sessions. + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-07-03 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("merchants") as batch_op: + batch_op.add_column(sa.Column("email", sa.String(255), nullable=True)) + batch_op.add_column(sa.Column("password_hash", sa.String(255), nullable=True)) + batch_op.alter_column("api_key_id", existing_type=sa.String(32), nullable=True) + batch_op.alter_column("api_key_hash", existing_type=sa.String(255), nullable=True) + batch_op.alter_column( + "webhook_secret", existing_type=sa.String(255), nullable=False, server_default="" + ) + op.create_index("ix_merchants_email", "merchants", ["email"], unique=True) + + op.create_table( + "merchant_sessions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id"), nullable=False), + sa.Column("session_id", sa.String(32), unique=True, nullable=False), + sa.Column("session_secret_hash", sa.String(255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_merchant_sessions_merchant_id", "merchant_sessions", ["merchant_id"]) + op.create_index("ix_merchant_sessions_session_id", "merchant_sessions", ["session_id"]) + + +def downgrade() -> None: + op.drop_index("ix_merchant_sessions_session_id", table_name="merchant_sessions") + op.drop_index("ix_merchant_sessions_merchant_id", table_name="merchant_sessions") + op.drop_table("merchant_sessions") + + op.drop_index("ix_merchants_email", table_name="merchants") + with op.batch_alter_table("merchants") as batch_op: + batch_op.alter_column("webhook_secret", existing_type=sa.String(255), nullable=False) + batch_op.alter_column("api_key_hash", existing_type=sa.String(255), nullable=False) + batch_op.alter_column("api_key_id", existing_type=sa.String(32), nullable=False) + batch_op.drop_column("password_hash") + batch_op.drop_column("email") diff --git a/somba/db/migrations/versions/0008_named_api_keys.py b/somba/db/migrations/versions/0008_named_api_keys.py new file mode 100644 index 0000000..1809975 --- /dev/null +++ b/somba/db/migrations/versions/0008_named_api_keys.py @@ -0,0 +1,84 @@ +"""Move API keys off the merchant row into a proper api_keys table. + +Merchants can now mint several named API keys from the dashboard (e.g. one +per environment) instead of holding a single unnamed key directly on the +merchant record. Existing single keys are carried over as a key named +"Default" before the old columns are dropped. + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-07-03 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0008" +down_revision = "0007" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_keys", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id"), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("key_id", sa.String(32), unique=True, nullable=False), + sa.Column("key_hash", sa.String(255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_api_keys_merchant_id", "api_keys", ["merchant_id"]) + op.create_index("ix_api_keys_key_id", "api_keys", ["key_id"]) + + conn = op.get_bind() + conn.execute( + sa.text( + """ + INSERT INTO api_keys (merchant_id, name, key_id, key_hash) + SELECT id, 'Default', api_key_id, api_key_hash + FROM merchants + WHERE api_key_id IS NOT NULL + """ + ) + ) + + with op.batch_alter_table("merchants") as batch_op: + batch_op.drop_index("ix_merchants_api_key_id") + batch_op.drop_column("api_key_hash") + batch_op.drop_column("api_key_id") + + +def downgrade() -> None: + with op.batch_alter_table("merchants") as batch_op: + batch_op.add_column(sa.Column("api_key_id", sa.String(32), nullable=True)) + batch_op.add_column(sa.Column("api_key_hash", sa.String(255), nullable=True)) + batch_op.create_index("ix_merchants_api_key_id", ["api_key_id"]) + + conn = op.get_bind() + conn.execute( + sa.text( + """ + UPDATE merchants + SET api_key_id = ( + SELECT key_id FROM api_keys + WHERE api_keys.merchant_id = merchants.id + ORDER BY api_keys.created_at ASC LIMIT 1 + ), + api_key_hash = ( + SELECT key_hash FROM api_keys + WHERE api_keys.merchant_id = merchants.id + ORDER BY api_keys.created_at ASC LIMIT 1 + ) + """ + ) + ) + + op.drop_index("ix_api_keys_key_id", table_name="api_keys") + op.drop_index("ix_api_keys_merchant_id", table_name="api_keys") + op.drop_table("api_keys") diff --git a/somba/db/models.py b/somba/db/models.py index d1d8b21..b128398 100644 --- a/somba/db/models.py +++ b/somba/db/models.py @@ -22,13 +22,56 @@ class Merchant(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - api_key_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) - api_key_hash: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True) + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) webhook_url: Mapped[str | None] = mapped_column(String(2048), nullable=True) - webhook_secret: Mapped[str] = mapped_column(String(255), nullable=False) + webhook_secret: Mapped[str] = mapped_column(String(255), nullable=False, default="") plans: Mapped[list["Plan"]] = relationship(back_populates="merchant") customers: Mapped[list["Customer"]] = relationship(back_populates="merchant") + sessions: Mapped[list["MerchantSession"]] = relationship(back_populates="merchant") + api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="merchant") + + +class ApiKey(Base): + """A named API key a merchant mints from the dashboard to use in their own code. + + A merchant can hold several — e.g. one per environment — each independently + named and revocable without touching the others. + """ + + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + merchant_id: Mapped[int] = mapped_column(ForeignKey("merchants.id"), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + key_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + key_hash: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + merchant: Mapped["Merchant"] = relationship(back_populates="api_keys") + + +class MerchantSession(Base): + """A dashboard login session — a separate credential from the API key. + + Merchants authenticate to the dashboard with email/password to manage their + account and mint API keys; they authenticate to the billing API itself with + the API key. Keeping the two credentials apart means rotating one never + invalidates the other. + """ + + __tablename__ = "merchant_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + merchant_id: Mapped[int] = mapped_column(ForeignKey("merchants.id"), index=True, nullable=False) + session_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + session_secret_hash: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + merchant: Mapped["Merchant"] = relationship(back_populates="sessions") class PlanStatus(str, Enum): diff --git a/somba/security.py b/somba/security.py index 671abdf..57be34a 100644 --- a/somba/security.py +++ b/somba/security.py @@ -8,6 +8,7 @@ import bcrypt API_KEY_PREFIX = "sk-somba-" +SESSION_TOKEN_PREFIX = "sess-somba-" @dataclass(frozen=True) @@ -63,3 +64,58 @@ def verify_api_key_secret(secret: str, secret_hash: str) -> bool: return bcrypt.checkpw(secret.encode("utf-8"), secret_hash.encode("utf-8")) except ValueError: return False + + +def hash_password(password: str) -> str: + """Hash a merchant dashboard password with bcrypt.""" + + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + """Check a merchant dashboard password against its stored bcrypt hash.""" + + try: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + except ValueError: + return False + + +@dataclass(frozen=True) +class SessionTokenMaterial: + """Convenience container for a generated dashboard session token.""" + + session_id: str + secret: str + token: str + secret_hash: str + + +def generate_session_token() -> SessionTokenMaterial: + """Generate a dashboard session token (separate credential from API keys).""" + + session_id = secrets.token_hex(8) + secret = secrets.token_urlsafe(32) + token = f"{SESSION_TOKEN_PREFIX}{session_id}.{secret}" + return SessionTokenMaterial( + session_id=session_id, + secret=secret, + token=token, + secret_hash=hash_api_key_secret(secret), + ) + + +def parse_session_token(token: str) -> tuple[str, str]: + """Split a session bearer token into session id and secret.""" + + if not token.startswith(SESSION_TOKEN_PREFIX): + raise ValueError("Session token must start with sess-somba-") + + body = token[len(SESSION_TOKEN_PREFIX) :] + if "." not in body: + raise ValueError("Session token must include a session id and secret") + + session_id, secret = body.split(".", 1) + if not session_id or not secret: + raise ValueError("Session token is missing a session id or secret") + return session_id, secret diff --git a/tests/conftest.py b/tests/conftest.py index 864125f..2ec46d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool -from somba.db.models import Base, Customer, Merchant, Plan, PlanStatus, Subscription, SubscriptionStatus +from somba.db.models import ApiKey, Base, Customer, Merchant, Plan, PlanStatus, Subscription, SubscriptionStatus from somba.security import generate_api_key_material @@ -39,15 +39,19 @@ def db(db_engine) -> Session: def make_merchant(db): """Factory: create and persist a merchant, return (merchant, raw_token).""" def _make(name: str = "Test Merchant") -> tuple[Merchant, str]: + m = Merchant(name=name, webhook_url=None, webhook_secret="whsec_test") + db.add(m) + db.flush() + key = generate_api_key_material() - m = Merchant( - name=name, - api_key_id=key.public_id, - api_key_hash=key.secret_hash, - webhook_url=None, - webhook_secret="whsec_test", + db.add( + ApiKey( + merchant_id=m.id, + name="Default", + key_id=key.public_id, + key_hash=key.secret_hash, + ) ) - db.add(m) db.commit() db.refresh(m) return m, key.token diff --git a/tests/unit/test_recovery_engine.py b/tests/unit/test_recovery_engine.py index d8eb527..a56ff74 100644 --- a/tests/unit/test_recovery_engine.py +++ b/tests/unit/test_recovery_engine.py @@ -131,9 +131,7 @@ def test_transfer_path_writes_no_recovery_schedule(db): def _make_real_subscription(db, *, customer_name: str | None = "Real Customer") -> Subscription: - merchant = Merchant( - name="M", api_key_id="k" * 16, api_key_hash="h", webhook_secret="s", - ) + merchant = Merchant(name="M", webhook_secret="s") db.add(merchant) db.flush() plan = Plan(merchant_id=merchant.id, name="P", amount=1000, currency="NGN", interval="month")