@voltro/cli 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +655 -0
- package/THIRD-PARTY-NOTICES.md +90 -4
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
- package/dist/apiBuild-CxvR1Dwt.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BMr-X48w.js +2427 -0
- package/dist/checkCommand-BOk9y264.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
- package/dist/dbCommand-D8nhyppR.js +2 -0
- package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
- package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
- package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
- package/dist/inspect-mmBuRXmy.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
- package/dist/manifestBuild-Dj8Jjoto.js +2 -0
- package/dist/sdkgen-CqpOq_fy.js +582 -0
- package/dist/serveCommand-Ciq_wIY8.js +1577 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +50 -0
- package/templates/AGENTS.md +54 -2
- package/templates/agent-docs/_index.md +4 -2
- package/templates/agent-docs/_manifest.json +26 -5
- package/templates/agent-docs/ai.md +191 -0
- package/templates/agent-docs/cli.md +143 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/internationalization.md +169 -8
- package/templates/agent-docs/local-first-mobile.md +426 -0
- package/templates/agent-docs/routing.md +94 -3
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/security.md +17 -0
- package/templates/agent-docs/templates/apibackends.md +210 -1
- package/templates/agent-docs/templates/appshells.md +144 -1
- package/templates/agent-docs/templates/mobile.md +60 -0
- package/templates/agent-docs/templates/overview.md +17 -7
- package/templates/agent-docs/whats-new.md +238 -158
- package/templates/agent-docs/workflows.md +376 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-collab/README.md +70 -0
- package/templates/apps/api-collab/app.config.ts +28 -0
- package/templates/apps/api-collab/database/schema.ts +63 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
- package/templates/apps/api-collab/package.json +30 -0
- package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
- package/templates/apps/api-collab/queries/documents.query.ts +23 -0
- package/templates/apps/api-collab/template.json +6 -0
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
- package/templates/apps/api-collab/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/app.config.ts +1 -1
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/app.config.ts +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/app.config.ts +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-collab/README.md +80 -0
- package/templates/apps/frontend-collab/app.config.ts +38 -0
- package/templates/apps/frontend-collab/package.json +33 -0
- package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
- package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
- package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
- package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
- package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
- package/templates/apps/frontend-collab/template.json +6 -0
- package/templates/apps/frontend-collab/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/app.config.ts +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/app.config.ts +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr/app.config.ts +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/apps/mobile-app/README.md +58 -0
- package/templates/apps/mobile-app/app.config.ts +33 -0
- package/templates/apps/mobile-app/babel.config.js +5 -0
- package/templates/apps/mobile-app/metro.config.js +20 -0
- package/templates/apps/mobile-app/package.json +33 -0
- package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
- package/templates/apps/mobile-app/src/app/index.tsx +62 -0
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
- package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
- package/templates/apps/mobile-app/src/client.ts +74 -0
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
- package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
- package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
- package/templates/apps/mobile-app/src/persistence.ts +38 -0
- package/templates/apps/mobile-app/template.json +6 -0
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
- package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
- package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
- package/templates/apps/mobile-app/tsconfig.json +10 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
- package/dist/apiBuild-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-ZTn-dPFa.js +0 -1425
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Layout for the SILENT route group `(marketing)/`. The parens mean the
|
|
2
|
+
// directory name never appears in a URL — the pages inside are `/` and
|
|
3
|
+
// `/login`. It gives the public pages a marketing header distinct from the
|
|
4
|
+
// dashboard's sidebar shell.
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import { T, useLocale, useT } from '@voltro/i18n'
|
|
7
|
+
import { LocaleSwitcher } from '@voltro/ui-shadcn'
|
|
8
|
+
import { APP_NAME } from '../../config'
|
|
9
|
+
|
|
10
|
+
const LOCALES = [
|
|
11
|
+
{ code: 'en', label: 'English' },
|
|
12
|
+
{ code: 'de', label: 'Deutsch' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export default function MarketingLayout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
16
|
+
const locale = useLocale()
|
|
17
|
+
const langLabel = useT('lang.label')
|
|
18
|
+
return (
|
|
19
|
+
<div className="marketing">
|
|
20
|
+
<header>
|
|
21
|
+
<a className="brand" href="/">{APP_NAME}</a>
|
|
22
|
+
<span className="spacer" />
|
|
23
|
+
<a href="/dashboard"><T id="nav.dashboard" /></a>
|
|
24
|
+
<a href="/login"><T id="nav.signIn" /></a>
|
|
25
|
+
<LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
|
|
26
|
+
</header>
|
|
27
|
+
{children}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The auth form. We assert config + that it renders email/password fields and
|
|
4
|
+
// toggles between sign-in and sign-up. The network submit (CSRF + POST /auth/*)
|
|
5
|
+
// is exercised against a running api by `voltro dev`, not unit-mocked here.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
8
|
+
import { act, createElement, type ReactNode } from 'react'
|
|
9
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
10
|
+
import { I18nProvider } from '@voltro/i18n'
|
|
11
|
+
import enCatalog from '../../../locales/en'
|
|
12
|
+
|
|
13
|
+
const { default: Login, meta, renderMode } = await import('./page')
|
|
14
|
+
|
|
15
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
16
|
+
|
|
17
|
+
let container: HTMLDivElement
|
|
18
|
+
let root: Root
|
|
19
|
+
|
|
20
|
+
const render = (node: ReactNode): void => {
|
|
21
|
+
container = document.createElement('div')
|
|
22
|
+
document.body.appendChild(container)
|
|
23
|
+
act(() => {
|
|
24
|
+
root = createRoot(container)
|
|
25
|
+
root.render(
|
|
26
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
if (root) act(() => root.unmount())
|
|
33
|
+
container?.remove()
|
|
34
|
+
document.body.innerHTML = ''
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('login — config', () => {
|
|
38
|
+
test('is a static page titled Sign in', () => {
|
|
39
|
+
expect(renderMode).toBe('static')
|
|
40
|
+
expect(meta({ locale: 'en' }).title).toBe('Sign in')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('login — form', () => {
|
|
45
|
+
test('renders email + password inputs and defaults to sign-in', () => {
|
|
46
|
+
render(createElement(Login))
|
|
47
|
+
expect(container.querySelector('input[type="email"]')).not.toBeNull()
|
|
48
|
+
expect(container.querySelector('input[type="password"]')).not.toBeNull()
|
|
49
|
+
expect(container.querySelector('h1')?.textContent).toBe('Sign in')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('toggling switches to the create-account heading', () => {
|
|
53
|
+
render(createElement(Login))
|
|
54
|
+
const toggle = container.querySelector('.link')!
|
|
55
|
+
act(() => { toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
|
56
|
+
expect(container.querySelector('h1')?.textContent).toBe('Create your account')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Sign in / sign up — URL `/login`. A REAL auth form: it POSTs to the api's
|
|
2
|
+
// `/auth/*` routes (contributed by @voltro/plugin-auth on the sibling api),
|
|
3
|
+
// which set the HttpOnly session cookie. State-changing auth routes are
|
|
4
|
+
// CSRF-protected, so we fetch a token first. On success we do a FULL navigation
|
|
5
|
+
// (`window.location.assign`) rather than a client one, so the dashboard's SSR
|
|
6
|
+
// auth gate re-runs with the freshly-set cookie.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { useState, type FormEvent } from 'react'
|
|
9
|
+
import type { PageMeta } from '@voltro/web'
|
|
10
|
+
import { T, useT } from '@voltro/i18n'
|
|
11
|
+
import { getCatalog } from '../../../locales/index'
|
|
12
|
+
|
|
13
|
+
export const renderMode = 'static' as const
|
|
14
|
+
|
|
15
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
16
|
+
title: getCatalog(locale)['meta.login.title'],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
type Mode = 'signIn' | 'signUp'
|
|
20
|
+
|
|
21
|
+
// One round-trip: get a CSRF token, then POST the credentials with it. Returns
|
|
22
|
+
// true on a 2xx (cookie set), false otherwise.
|
|
23
|
+
async function submitAuth(mode: Mode, email: string, password: string): Promise<boolean> {
|
|
24
|
+
const csrf = (await fetch('/auth/csrf').then((r) => r.json())) as { csrfToken: string }
|
|
25
|
+
const path = mode === 'signUp' ? '/auth/sign-up' : '/auth/sign-in'
|
|
26
|
+
const res = await fetch(path, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': csrf.csrfToken },
|
|
29
|
+
body: JSON.stringify({ email, password }),
|
|
30
|
+
})
|
|
31
|
+
return res.ok
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function Login(): ReactNode {
|
|
35
|
+
const [mode, setMode] = useState<Mode>('signIn')
|
|
36
|
+
const [email, setEmail] = useState('')
|
|
37
|
+
const [password, setPassword] = useState('')
|
|
38
|
+
const [pending, setPending] = useState(false)
|
|
39
|
+
const [failed, setFailed] = useState(false)
|
|
40
|
+
const emailLabel = useT('auth.email')
|
|
41
|
+
const passwordLabel = useT('auth.password')
|
|
42
|
+
|
|
43
|
+
const onSubmit = (e: FormEvent): void => {
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
setPending(true)
|
|
46
|
+
setFailed(false)
|
|
47
|
+
void submitAuth(mode, email, password)
|
|
48
|
+
.then((ok) => {
|
|
49
|
+
if (ok) {
|
|
50
|
+
const from = new URLSearchParams(window.location.search).get('from') ?? '/dashboard'
|
|
51
|
+
window.location.assign(from)
|
|
52
|
+
} else {
|
|
53
|
+
setFailed(true)
|
|
54
|
+
setPending(false)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
setFailed(true)
|
|
59
|
+
setPending(false)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<section className="hero">
|
|
65
|
+
<h1><T id={mode === 'signUp' ? 'auth.signUp.heading' : 'auth.signIn.heading'} /></h1>
|
|
66
|
+
<form className="auth-form" onSubmit={onSubmit}>
|
|
67
|
+
<label>
|
|
68
|
+
<span><T id="auth.email" /></span>
|
|
69
|
+
<input
|
|
70
|
+
type="email" required aria-label={emailLabel} value={email}
|
|
71
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
72
|
+
/>
|
|
73
|
+
</label>
|
|
74
|
+
<label>
|
|
75
|
+
<span><T id="auth.password" /></span>
|
|
76
|
+
<input
|
|
77
|
+
type="password" required aria-label={passwordLabel} value={password}
|
|
78
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
79
|
+
/>
|
|
80
|
+
</label>
|
|
81
|
+
{failed ? <p className="error-text"><T id="auth.error" /></p> : null}
|
|
82
|
+
<button type="submit" className="btn" disabled={pending}>
|
|
83
|
+
<T id={pending ? 'auth.pending' : mode === 'signUp' ? 'auth.signUp.submit' : 'auth.signIn.submit'} />
|
|
84
|
+
</button>
|
|
85
|
+
</form>
|
|
86
|
+
<p style={{ marginTop: '1rem' }}>
|
|
87
|
+
<button
|
|
88
|
+
type="button" className="link"
|
|
89
|
+
onClick={() => { setMode((m) => (m === 'signIn' ? 'signUp' : 'signIn')); setFailed(false) }}
|
|
90
|
+
>
|
|
91
|
+
<T id={mode === 'signIn' ? 'auth.toggle.toSignUp' : 'auth.toggle.toSignIn'} />
|
|
92
|
+
</button>
|
|
93
|
+
</p>
|
|
94
|
+
</section>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Public landing — URL `/` (the `(marketing)` group is silent). Static content,
|
|
2
|
+
// so it pre-renders to flat HTML and ships zero JS by default.
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import type { PageMeta } from '@voltro/web'
|
|
5
|
+
import { T } from '@voltro/i18n'
|
|
6
|
+
import { getCatalog } from '../../locales'
|
|
7
|
+
|
|
8
|
+
export const renderMode = 'static' as const
|
|
9
|
+
|
|
10
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
11
|
+
title: getCatalog(locale)['meta.landing.title'],
|
|
12
|
+
description: getCatalog(locale)['meta.landing.description'],
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export default function Landing(): ReactNode {
|
|
16
|
+
return (
|
|
17
|
+
<section className="hero">
|
|
18
|
+
<h1><T id="landing.heading" /></h1>
|
|
19
|
+
<p className="muted">
|
|
20
|
+
<T id="landing.body" values={{ code: (c: ReactNode) => <code>{c}</code> }} />
|
|
21
|
+
</p>
|
|
22
|
+
<p style={{ marginTop: '1.5rem' }}>
|
|
23
|
+
<a className="btn" href="/login"><T id="landing.cta" /></a>
|
|
24
|
+
</p>
|
|
25
|
+
</section>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Billing — URL `/dashboard/billing`. Reads the tenant's subscription live
|
|
2
|
+
// (`billing.subscription`, contributed by @voltro/plugin-billing) and drives the
|
|
3
|
+
// two write paths the plugin exposes: `billing.startCheckout` (upgrade to Pro →
|
|
4
|
+
// redirect to the provider's hosted checkout; the `mock` provider returns a
|
|
5
|
+
// dev URL) and `billing.changeSeats` (raise the seat quantity). No subscription
|
|
6
|
+
// row yet means the tenant is on the implicit free plan.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import type { PageMeta } from '@voltro/web'
|
|
9
|
+
import { useSubscription, useAction } from '@voltro/client'
|
|
10
|
+
import { T, useT } from '@voltro/i18n'
|
|
11
|
+
import { getCatalog } from '../../../locales/index'
|
|
12
|
+
import type { SubscriptionState } from '../../../lib/api'
|
|
13
|
+
|
|
14
|
+
export const renderMode = 'ssr' as const
|
|
15
|
+
|
|
16
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
17
|
+
title: getCatalog(locale)['meta.billing.title'],
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
interface CheckoutInput { plan: string; successUrl: string; cancelUrl: string }
|
|
21
|
+
interface SeatsInput { quantity: number }
|
|
22
|
+
|
|
23
|
+
export default function Billing(): ReactNode {
|
|
24
|
+
const { data } = useSubscription<SubscriptionState>('app', 'billing.subscription')
|
|
25
|
+
const checkout = useAction<CheckoutInput, { url: string }>('app', 'billing.startCheckout')
|
|
26
|
+
const seats = useAction<SeatsInput, unknown>('app', 'billing.changeSeats')
|
|
27
|
+
|
|
28
|
+
const sub = data?.subscription ?? null
|
|
29
|
+
const planLabel = useT('billing.plan.free')
|
|
30
|
+
const plan = sub?.plan ?? planLabel
|
|
31
|
+
const quantity = sub?.quantity ?? 1
|
|
32
|
+
const busy = checkout.pending || seats.pending
|
|
33
|
+
|
|
34
|
+
const onUpgrade = (): void => {
|
|
35
|
+
const here = typeof window === 'undefined' ? '/dashboard/billing' : window.location.href
|
|
36
|
+
void checkout.run({ plan: 'pro', successUrl: here, cancelUrl: here })
|
|
37
|
+
.then((r) => { if (r && typeof window !== 'undefined') window.location.assign(r.url) })
|
|
38
|
+
.catch(() => { /* surfaced via checkout.error */ })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const onAddSeat = (): void => {
|
|
42
|
+
void seats.run({ quantity: quantity + 1 }).catch(() => { /* surfaced via seats.error */ })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<section>
|
|
47
|
+
<h1><T id="billing.heading" /></h1>
|
|
48
|
+
|
|
49
|
+
<dl className="facts">
|
|
50
|
+
<dt><T id="billing.plan.label" /></dt>
|
|
51
|
+
<dd>{plan}</dd>
|
|
52
|
+
<dt><T id="billing.status.label" /></dt>
|
|
53
|
+
<dd>{sub?.status ?? '—'}</dd>
|
|
54
|
+
<dt><T id="billing.seats.label" /></dt>
|
|
55
|
+
<dd>{quantity}</dd>
|
|
56
|
+
</dl>
|
|
57
|
+
|
|
58
|
+
<div className="paywall" style={{ marginTop: '1.5rem' }}>
|
|
59
|
+
<strong><T id="billing.upgrade.title" /></strong>
|
|
60
|
+
<p className="muted"><T id="billing.upgrade.body" /></p>
|
|
61
|
+
<div className="row">
|
|
62
|
+
<button type="button" className="btn" disabled={busy} onClick={onUpgrade}>
|
|
63
|
+
<T id={checkout.pending ? 'billing.pending' : 'billing.upgrade.cta'} />
|
|
64
|
+
</button>
|
|
65
|
+
<button type="button" className="btn ghost" disabled={busy} onClick={onAddSeat}>
|
|
66
|
+
<T id={seats.pending ? 'billing.pending' : 'billing.seats.add'} />
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Error boundary scoped to the /dashboard subtree. A render error (or a loader
|
|
2
|
+
// rejection that ISN'T a RedirectError / NotFoundError) bubbles to the nearest
|
|
3
|
+
// `error.tsx` — this one. `reset` re-runs the failed segment.
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import type { ErrorBoundaryProps } from '@voltro/web'
|
|
6
|
+
import { T } from '@voltro/i18n'
|
|
7
|
+
|
|
8
|
+
export default function DashboardError({ error, reset }: ErrorBoundaryProps): ReactNode {
|
|
9
|
+
return (
|
|
10
|
+
<div className="dash">
|
|
11
|
+
<main>
|
|
12
|
+
<h1><T id="error.heading" /></h1>
|
|
13
|
+
<p className="muted">{String(error)}</p>
|
|
14
|
+
<p style={{ marginTop: '1rem' }}>
|
|
15
|
+
<button type="button" className="btn" onClick={reset}><T id="error.retry" /></button>
|
|
16
|
+
</p>
|
|
17
|
+
</main>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The dashboard auth GATE, tested at the loader. Server-side (a `query` fn is
|
|
2
|
+
// present) it asks the api's `session.me`: an anonymous identity must throw
|
|
3
|
+
// RedirectError to /login, a `user` identity resolves with the tenant. On a
|
|
4
|
+
// client navigation (`query` undefined) it must resolve permissively (the entry
|
|
5
|
+
// load already gated; the HttpOnly cookie isn't readable in JS).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
8
|
+
import { RedirectError } from '@voltro/web'
|
|
9
|
+
import { loader } from './layout'
|
|
10
|
+
import type { Identity } from '../../lib/api'
|
|
11
|
+
|
|
12
|
+
const queryReturning = (identity: Identity) =>
|
|
13
|
+
vi.fn(async (_tag: string, _input: unknown) => identity as unknown)
|
|
14
|
+
|
|
15
|
+
describe('dashboard gate — loader', () => {
|
|
16
|
+
test('redirects an anonymous caller to /login', async () => {
|
|
17
|
+
const query = queryReturning({ type: 'anonymous', id: null, tenantId: null })
|
|
18
|
+
await expect(loader({ query } as never)).rejects.toBeInstanceOf(RedirectError)
|
|
19
|
+
expect(query).toHaveBeenCalledWith('session.me', {})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('lets a signed-in user through and returns the tenant', async () => {
|
|
23
|
+
const query = queryReturning({ type: 'user', id: 'u_1', tenantId: 'acme' })
|
|
24
|
+
const data = await loader({ query } as never)
|
|
25
|
+
expect(data).toEqual({ tenantId: 'acme' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('client navigation (no server query fn) resolves without re-gating', async () => {
|
|
29
|
+
const data = await loader({ query: undefined } as never)
|
|
30
|
+
expect(data).toEqual({ tenantId: null })
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The dashboard shell — and the AUTH GATE. This nested layout wraps every page
|
|
2
|
+
// under `/dashboard`, and its `loader` runs server-side BEFORE the pages render.
|
|
3
|
+
// It asks the api who the caller is (`session.me`) and throws RedirectError to
|
|
4
|
+
// /login for anyone not signed in. The pages under here are `renderMode: 'ssr'`
|
|
5
|
+
// precisely so this runs per request — a `static` page can't redirect
|
|
6
|
+
// per-visitor, and the build rejects a RedirectError there.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { RedirectError, useLoaderData, useLocation, type LoaderFn } from '@voltro/web'
|
|
9
|
+
import { T, useLocale, useT } from '@voltro/i18n'
|
|
10
|
+
import { LocaleSwitcher } from '@voltro/ui-shadcn'
|
|
11
|
+
import { APP_NAME } from '../../config'
|
|
12
|
+
import type { Identity } from '../../lib/api'
|
|
13
|
+
|
|
14
|
+
const LOCALES = [
|
|
15
|
+
{ code: 'en', label: 'English' },
|
|
16
|
+
{ code: 'de', label: 'Deutsch' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
interface DashboardData {
|
|
20
|
+
readonly tenantId: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const loader: LoaderFn<DashboardData> = async ({ query }) => {
|
|
24
|
+
// `query` is present ONLY server-side (the SSR full-page load). There, ask the
|
|
25
|
+
// api's `session.me` — it forwards the request's session cookie, so the SAME
|
|
26
|
+
// Subject resolves as the WS path — and bounce an anonymous caller to /login.
|
|
27
|
+
if (query) {
|
|
28
|
+
const me = await query<Identity>('session.me', {})
|
|
29
|
+
if (me.type !== 'user') {
|
|
30
|
+
throw new RedirectError('/login?from=/dashboard')
|
|
31
|
+
}
|
|
32
|
+
return { tenantId: me.tenantId }
|
|
33
|
+
}
|
|
34
|
+
// Client-side navigation within /dashboard: the entry load already gated, and
|
|
35
|
+
// every rpc/subscription is independently session-authed on the server (the
|
|
36
|
+
// HttpOnly cookie isn't readable in JS), so we don't re-check here.
|
|
37
|
+
return { tenantId: null }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function DashboardLayout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
41
|
+
useLoaderData<DashboardData>()
|
|
42
|
+
const pathname = useLocation()
|
|
43
|
+
const locale = useLocale()
|
|
44
|
+
const langLabel = useT('lang.label')
|
|
45
|
+
const navItem = (href: string, label: ReactNode): ReactNode => (
|
|
46
|
+
<a href={href} aria-current={pathname === href ? 'page' : undefined}>{label}</a>
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="dash">
|
|
51
|
+
<aside>
|
|
52
|
+
<div className="brand">{APP_NAME}</div>
|
|
53
|
+
<nav>
|
|
54
|
+
{navItem('/dashboard', <T id="dash.nav.projects" />)}
|
|
55
|
+
{navItem('/dashboard/billing', <T id="dash.nav.billing" />)}
|
|
56
|
+
{navItem('/dashboard/team', <T id="dash.nav.team" />)}
|
|
57
|
+
<a href="/"><T id="dash.nav.backToSite" /></a>
|
|
58
|
+
</nav>
|
|
59
|
+
</aside>
|
|
60
|
+
<main>
|
|
61
|
+
<div className="topbar">
|
|
62
|
+
<span className="spacer" />
|
|
63
|
+
<LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
|
|
64
|
+
</div>
|
|
65
|
+
{children}
|
|
66
|
+
</main>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Scoped 404 for the /dashboard subtree. Fires when a URL is under
|
|
2
|
+
// `/dashboard/...` but no concrete page matches, and when a loader throws
|
|
3
|
+
// NotFoundError. The router picks the deepest matching not-found scope.
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import { T } from '@voltro/i18n'
|
|
6
|
+
|
|
7
|
+
export default function DashboardNotFound(): ReactNode {
|
|
8
|
+
return (
|
|
9
|
+
<div className="dash">
|
|
10
|
+
<main>
|
|
11
|
+
<h1><T id="notFound.heading" /></h1>
|
|
12
|
+
<p className="muted"><T id="notFound.body" /></p>
|
|
13
|
+
<p style={{ marginTop: '1rem' }}><a href="/dashboard"><T id="notFound.back" /></a></p>
|
|
14
|
+
</main>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// Projects page — the live list + the paywall branch. We mock `@voltro/client`
|
|
4
|
+
// (useSubscription streams the rows; useMutation is the create) and drive the
|
|
5
|
+
// create form, asserting: the SSR'd rows render, the empty state shows, and a
|
|
6
|
+
// `projects.create` that rejects with a typed `EntitlementExceeded` flips the
|
|
7
|
+
// page into the upgrade CTA (NOT a raw error).
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
10
|
+
import { act, createElement, type ReactNode } from 'react'
|
|
11
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
12
|
+
import { I18nProvider } from '@voltro/i18n'
|
|
13
|
+
import enCatalog from '../../locales/en'
|
|
14
|
+
|
|
15
|
+
const subData = vi.fn<() => { data: unknown }>()
|
|
16
|
+
const mutate = vi.fn<(input: { name: string }) => Promise<unknown>>()
|
|
17
|
+
vi.mock('@voltro/client', () => ({
|
|
18
|
+
useSubscription: () => subData(),
|
|
19
|
+
useMutation: () => ({ mutate, pending: false, error: undefined, data: undefined }),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const { default: Projects, meta, renderMode } = await import('./page')
|
|
23
|
+
|
|
24
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
25
|
+
|
|
26
|
+
let container: HTMLDivElement
|
|
27
|
+
let root: Root
|
|
28
|
+
|
|
29
|
+
const render = (node: ReactNode): void => {
|
|
30
|
+
container = document.createElement('div')
|
|
31
|
+
document.body.appendChild(container)
|
|
32
|
+
act(() => {
|
|
33
|
+
root = createRoot(container)
|
|
34
|
+
root.render(
|
|
35
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
subData.mockReset()
|
|
42
|
+
subData.mockReturnValue({ data: undefined })
|
|
43
|
+
mutate.mockReset()
|
|
44
|
+
mutate.mockResolvedValue({ id: 'proj_1', name: 'x', tenantId: 'acme' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
if (root) act(() => root.unmount())
|
|
49
|
+
container?.remove()
|
|
50
|
+
document.body.innerHTML = ''
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('projects — config', () => {
|
|
54
|
+
test('is an SSR page titled Projects (so the parent gate runs per request)', () => {
|
|
55
|
+
expect(renderMode).toBe('ssr')
|
|
56
|
+
expect(meta({ locale: 'en' }).title).toBe('Projects')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('projects — render', () => {
|
|
61
|
+
test('shows the empty state when there are no projects', () => {
|
|
62
|
+
render(createElement(Projects))
|
|
63
|
+
expect(container.textContent).toContain('No projects yet')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('renders one card per streamed project', () => {
|
|
67
|
+
subData.mockReturnValue({ data: [
|
|
68
|
+
{ id: 'proj_1', name: 'Acme', tenantId: 'acme', createdAt: '2026-01-01T00:00:00Z' },
|
|
69
|
+
{ id: 'proj_2', name: 'Beta', tenantId: 'acme', createdAt: '2026-01-02T00:00:00Z' },
|
|
70
|
+
] })
|
|
71
|
+
render(createElement(Projects))
|
|
72
|
+
expect(container.querySelectorAll('.card')).toHaveLength(2)
|
|
73
|
+
expect(container.textContent).toContain('Acme')
|
|
74
|
+
expect(container.textContent).toContain('Beta')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('projects — paywall', () => {
|
|
79
|
+
// React 19 tracks the input's value, so a raw `input.value = …` is ignored —
|
|
80
|
+
// set it through the prototype's native setter so the tracked value changes
|
|
81
|
+
// and `onChange` fires with the new value.
|
|
82
|
+
const setInputValue = (el: HTMLInputElement, value: string): void => {
|
|
83
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
|
84
|
+
setter?.call(el, value)
|
|
85
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
86
|
+
}
|
|
87
|
+
const submit = async (): Promise<void> => {
|
|
88
|
+
const input = container.querySelector('input')!
|
|
89
|
+
const form = container.querySelector('form')!
|
|
90
|
+
act(() => { setInputValue(input, 'Fourth') })
|
|
91
|
+
await act(async () => {
|
|
92
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
93
|
+
// Flush the mutate() promise chain (.then/.catch) + its setState.
|
|
94
|
+
await Promise.resolve()
|
|
95
|
+
await Promise.resolve()
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('a create that succeeds does NOT show the paywall', async () => {
|
|
100
|
+
render(createElement(Projects))
|
|
101
|
+
await submit()
|
|
102
|
+
expect(mutate).toHaveBeenCalledWith({ name: 'Fourth' })
|
|
103
|
+
expect(container.querySelector('.paywall')).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('a typed EntitlementExceeded flips the page into the upgrade CTA', async () => {
|
|
107
|
+
mutate.mockRejectedValue({ _tag: 'EntitlementExceeded', entitlement: 'projects', limit: 3 })
|
|
108
|
+
render(createElement(Projects))
|
|
109
|
+
await submit()
|
|
110
|
+
expect(container.querySelector('.paywall')).not.toBeNull()
|
|
111
|
+
expect(container.textContent).toContain('hit your plan limit')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Projects — URL `/dashboard`. The live app screen AND the paywall showcase.
|
|
2
|
+
// `useSubscription` streams the tenant's projects (first paint from the layout
|
|
3
|
+
// gate, upgraded live on WS connect). Creating one calls `projects.create`,
|
|
4
|
+
// which the api gates on the billing `projects` entitlement — the 4th on the
|
|
5
|
+
// free plan rejects with a typed `EntitlementExceeded`, and we branch on that
|
|
6
|
+
// (`isEntitlementExceeded`) to show the upgrade CTA instead of a raw error.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { useState, type FormEvent } from 'react'
|
|
9
|
+
import type { PageMeta } from '@voltro/web'
|
|
10
|
+
import { useSubscription, useMutation } from '@voltro/client'
|
|
11
|
+
import { T, useT } from '@voltro/i18n'
|
|
12
|
+
import { getCatalog } from '../../locales'
|
|
13
|
+
import { isEntitlementExceeded, type Project } from '../../lib/api'
|
|
14
|
+
|
|
15
|
+
export const renderMode = 'ssr' as const
|
|
16
|
+
|
|
17
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
18
|
+
title: getCatalog(locale)['meta.overview.title'],
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export default function Projects(): ReactNode {
|
|
22
|
+
const { data: live } = useSubscription<ReadonlyArray<Project>>('app', 'projects.list')
|
|
23
|
+
const { mutate, pending } = useMutation<{ name: string }, Project>('app', 'projects.create')
|
|
24
|
+
const projects = live ?? []
|
|
25
|
+
const [name, setName] = useState('')
|
|
26
|
+
const [paywalled, setPaywalled] = useState(false)
|
|
27
|
+
const placeholder = useT('projects.new.placeholder')
|
|
28
|
+
|
|
29
|
+
const onCreate = (e: FormEvent): void => {
|
|
30
|
+
e.preventDefault()
|
|
31
|
+
const trimmed = name.trim()
|
|
32
|
+
if (trimmed === '') return
|
|
33
|
+
void mutate({ name: trimmed })
|
|
34
|
+
.then(() => { setName(''); setPaywalled(false) })
|
|
35
|
+
.catch((err: unknown) => { if (isEntitlementExceeded(err)) setPaywalled(true) })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<section>
|
|
40
|
+
<h1><T id="projects.heading" /></h1>
|
|
41
|
+
|
|
42
|
+
<form className="row" onSubmit={onCreate}>
|
|
43
|
+
<input
|
|
44
|
+
aria-label={placeholder} placeholder={placeholder}
|
|
45
|
+
value={name} onChange={(e) => setName(e.target.value)}
|
|
46
|
+
/>
|
|
47
|
+
<button type="submit" className="btn" disabled={pending}>
|
|
48
|
+
<T id="projects.new.submit" />
|
|
49
|
+
</button>
|
|
50
|
+
</form>
|
|
51
|
+
|
|
52
|
+
{paywalled ? (
|
|
53
|
+
<div className="paywall" role="alert">
|
|
54
|
+
<strong><T id="projects.paywall.title" /></strong>
|
|
55
|
+
<p className="muted"><T id="projects.paywall.body" /></p>
|
|
56
|
+
<a className="btn" href="/dashboard/billing"><T id="projects.paywall.cta" /></a>
|
|
57
|
+
</div>
|
|
58
|
+
) : null}
|
|
59
|
+
|
|
60
|
+
{projects.length === 0 ? (
|
|
61
|
+
<p className="empty"><T id="projects.empty" /></p>
|
|
62
|
+
) : (
|
|
63
|
+
<ul className="cards" style={{ marginTop: '1.25rem' }}>
|
|
64
|
+
{projects.map((p) => (
|
|
65
|
+
<li className="card" key={p.id}>
|
|
66
|
+
<div className="value">{p.name}</div>
|
|
67
|
+
<div className="label">
|
|
68
|
+
<T id="projects.created" values={{ when: new Date(p.createdAt).toLocaleDateString() }} />
|
|
69
|
+
</div>
|
|
70
|
+
</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
)}
|
|
74
|
+
</section>
|
|
75
|
+
)
|
|
76
|
+
}
|