@voltro/cli 0.27.0 → 0.29.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.
Files changed (284) hide show
  1. package/CHANGELOG.md +513 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-D22_EpoR.js → apiBuild-BESZbTjA.js} +4 -4
  4. package/dist/apiBuild-C8d74T6g.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BPAtyWs8.js +2420 -0
  7. package/dist/checkCommand-DBvZvCh1.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-jBX8no1I.js → commands-82BDEktg.js} +6476 -7672
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-uuNCrFAb.js → dbCommand-bCBbk6tz.js} +3 -3
  12. package/dist/{dev-DcbIJrWg.js → dev-Cg4BKLmi.js} +3034 -2619
  13. package/dist/dev-DeEJj5T4.js +3 -0
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
  16. package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
  17. package/dist/index.js +1 -1
  18. package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
  19. package/dist/inspect-DHeaMZak.js +2 -0
  20. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
  21. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
  22. package/dist/manifestBuild-Ck_90gZy.js +2 -0
  23. package/dist/sdkgen-CW5NbYF0.js +582 -0
  24. package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
  25. package/dist/serveCommand-BXl8mhZD.js +1489 -0
  26. package/dist/serveEntry.js +3 -3
  27. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  28. package/dist/startEntry.js +2 -2
  29. package/package.json +17 -17
  30. package/templates/AGENTS.md +2 -1
  31. package/templates/agent-docs/_index.md +2 -1
  32. package/templates/agent-docs/_manifest.json +10 -1
  33. package/templates/agent-docs/ai.md +135 -0
  34. package/templates/agent-docs/cli.md +124 -6
  35. package/templates/agent-docs/configuration.md +32 -0
  36. package/templates/agent-docs/data.md +282 -0
  37. package/templates/agent-docs/database/migrations.md +47 -0
  38. package/templates/agent-docs/database/misc.md +57 -0
  39. package/templates/agent-docs/database/schema.md +3 -1
  40. package/templates/agent-docs/deployment.md +56 -0
  41. package/templates/agent-docs/internationalization.md +161 -1
  42. package/templates/agent-docs/local-first-mobile.md +414 -0
  43. package/templates/agent-docs/routing.md +93 -2
  44. package/templates/agent-docs/schema-driven-ui.md +12 -0
  45. package/templates/agent-docs/templates/apibackends.md +1 -1
  46. package/templates/agent-docs/testing.md +42 -0
  47. package/templates/agent-docs/whats-new.md +165 -131
  48. package/templates/agent-docs/workflows.md +11 -0
  49. package/templates/apps/api-ai/package.json +7 -7
  50. package/templates/apps/api-auth/package.json +8 -8
  51. package/templates/apps/api-backend/package.json +7 -7
  52. package/templates/apps/api-backend-deactivation/package.json +7 -7
  53. package/templates/apps/api-backend-mail/package.json +8 -8
  54. package/templates/apps/api-backend-mariadb/package.json +9 -9
  55. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  56. package/templates/apps/api-backend-sqlite/README.md +38 -0
  57. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  58. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  59. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  60. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  61. package/templates/apps/api-backend-sqlite/package.json +30 -0
  62. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  63. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  64. package/templates/apps/api-backend-sqlite/template.json +6 -0
  65. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  66. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  67. package/templates/apps/api-backend-storage/package.json +8 -8
  68. package/templates/apps/api-cms/README.md +104 -0
  69. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  70. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  71. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  72. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  73. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  74. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  75. package/templates/apps/api-cms/app.config.ts +61 -0
  76. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  77. package/templates/apps/api-cms/content/index.ts +18 -0
  78. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  79. package/templates/apps/api-cms/database/schema.ts +64 -0
  80. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  81. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  82. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  83. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  84. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  85. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  86. package/templates/apps/api-cms/package.json +32 -0
  87. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  88. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  89. package/templates/apps/api-cms/template.json +6 -0
  90. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  91. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  92. package/templates/apps/api-cms/tsconfig.json +5 -0
  93. package/templates/apps/api-data-advanced/package.json +8 -8
  94. package/templates/apps/api-durable/package.json +8 -8
  95. package/templates/apps/api-feature-flags/package.json +9 -9
  96. package/templates/apps/api-governance/package.json +8 -8
  97. package/templates/apps/api-kv/package.json +8 -8
  98. package/templates/apps/api-moderation/package.json +8 -8
  99. package/templates/apps/api-observability/package.json +8 -8
  100. package/templates/apps/api-ratelimit/package.json +8 -8
  101. package/templates/apps/api-rbac/package.json +8 -8
  102. package/templates/apps/api-rest/package.json +7 -7
  103. package/templates/apps/api-saas/package.json +11 -11
  104. package/templates/apps/api-saas-starter/README.md +103 -0
  105. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  106. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  107. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  108. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  109. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  110. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  111. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  112. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  113. package/templates/apps/api-saas-starter/package.json +32 -0
  114. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  115. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  116. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  117. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  118. package/templates/apps/api-saas-starter/template.json +6 -0
  119. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  120. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  121. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  122. package/templates/apps/api-search/package.json +8 -8
  123. package/templates/apps/api-status/README.md +88 -0
  124. package/templates/apps/api-status/app.config.ts +36 -0
  125. package/templates/apps/api-status/authz.ts +33 -0
  126. package/templates/apps/api-status/database/schema.ts +70 -0
  127. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  128. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  129. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  130. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  131. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  132. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  133. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  134. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  135. package/templates/apps/api-status/package.json +30 -0
  136. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  137. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  138. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  139. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  140. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  141. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  142. package/templates/apps/api-status/template.json +6 -0
  143. package/templates/apps/api-status/tests/status.test.ts +70 -0
  144. package/templates/apps/api-status/tsconfig.json +5 -0
  145. package/templates/apps/api-versioning/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/package.json +6 -6
  148. package/templates/apps/edge-functions/package.json +2 -2
  149. package/templates/apps/frontend-admin/package.json +8 -8
  150. package/templates/apps/frontend-app/package.json +8 -8
  151. package/templates/apps/frontend-auth/README.md +78 -0
  152. package/templates/apps/frontend-auth/app.config.ts +34 -0
  153. package/templates/apps/frontend-auth/package.json +32 -0
  154. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  155. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  156. package/templates/apps/frontend-auth/src/config.ts +11 -0
  157. package/templates/apps/frontend-auth/src/globals.css +105 -0
  158. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  159. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  160. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  161. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  162. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  163. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  164. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  165. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  166. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  167. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  168. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  169. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  170. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  171. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  172. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  173. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  174. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  175. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  176. package/templates/apps/frontend-auth/template.json +6 -0
  177. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  178. package/templates/apps/frontend-blank/package.json +7 -7
  179. package/templates/apps/frontend-cms/README.md +47 -0
  180. package/templates/apps/frontend-cms/app.config.ts +37 -0
  181. package/templates/apps/frontend-cms/package.json +33 -0
  182. package/templates/apps/frontend-cms/src/config.ts +8 -0
  183. package/templates/apps/frontend-cms/src/globals.css +105 -0
  184. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  185. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  186. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  187. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  188. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  192. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  193. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  194. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  195. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  196. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  197. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  198. package/templates/apps/frontend-cms/template.json +6 -0
  199. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  200. package/templates/apps/frontend-contact/package.json +7 -7
  201. package/templates/apps/frontend-dashboard/package.json +7 -7
  202. package/templates/apps/frontend-docs/package.json +7 -7
  203. package/templates/apps/frontend-i18n/package.json +6 -6
  204. package/templates/apps/frontend-landing/package.json +7 -7
  205. package/templates/apps/frontend-portal/README.md +71 -0
  206. package/templates/apps/frontend-portal/app.config.ts +37 -0
  207. package/templates/apps/frontend-portal/package.json +32 -0
  208. package/templates/apps/frontend-portal/src/config.ts +8 -0
  209. package/templates/apps/frontend-portal/src/globals.css +93 -0
  210. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  211. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  212. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  213. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  214. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  223. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  224. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  225. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  226. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  227. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  228. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  229. package/templates/apps/frontend-portal/template.json +6 -0
  230. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  231. package/templates/apps/frontend-saas/README.md +73 -0
  232. package/templates/apps/frontend-saas/app.config.ts +46 -0
  233. package/templates/apps/frontend-saas/package.json +32 -0
  234. package/templates/apps/frontend-saas/src/config.ts +8 -0
  235. package/templates/apps/frontend-saas/src/globals.css +85 -0
  236. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  237. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  238. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  239. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  240. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  242. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  243. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  244. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  250. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  251. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  252. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  253. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  254. package/templates/apps/frontend-saas/template.json +6 -0
  255. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  256. package/templates/apps/frontend-spa/package.json +7 -7
  257. package/templates/apps/frontend-ssr/package.json +7 -7
  258. package/templates/apps/frontend-ssr-api/package.json +8 -8
  259. package/templates/apps/frontend-static-blog/package.json +6 -6
  260. package/templates/apps/frontend-status/README.md +51 -0
  261. package/templates/apps/frontend-status/app.config.ts +39 -0
  262. package/templates/apps/frontend-status/package.json +32 -0
  263. package/templates/apps/frontend-status/src/config.ts +7 -0
  264. package/templates/apps/frontend-status/src/globals.css +70 -0
  265. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  266. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  267. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  268. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  269. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  270. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  271. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  272. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  273. package/templates/apps/frontend-status/template.json +6 -0
  274. package/templates/apps/frontend-status/tsconfig.json +11 -0
  275. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  276. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  277. package/dist/apiBuild-BrjrVJJh.js +0 -2
  278. package/dist/dbCommand-DrzXimKf.js +0 -2
  279. package/dist/dev-DNkso403.js +0 -3
  280. package/dist/fileConventions-3bffWssN.js +0 -30
  281. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  282. package/dist/inspect-gt8bq-Tz.js +0 -2
  283. package/dist/manifestBuild-ifczArzr.js +0 -2
  284. package/dist/serveCommand-DfkisVWP.js +0 -1310
@@ -0,0 +1,97 @@
1
+ // Billing — URL `/billing`. The current plan (live from `billing.subscription`),
2
+ // a card-management button that redirects to the PROVIDER's hosted portal
3
+ // (`billing.portalUrl`), and a plan change that QUOTES the provider's prorated
4
+ // settlement first (`billing.previewChange`) before applying it
5
+ // (`billing.changePlan`) — never a locally-estimated figure.
6
+ import type { ReactNode } from 'react'
7
+ import { useState } from 'react'
8
+ import type { PageMeta } from '@voltro/web'
9
+ import { useSubscription, useAction } from '@voltro/client'
10
+ import { T, useLocale, useT } from '@voltro/i18n'
11
+ import { getCatalog } from '../../../locales/index'
12
+ import { formatMoney, type ChangePreview, 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
+ // The plan a free tenant upgrades to — matches api-saas-starter's `plans`.
21
+ const UPGRADE_PLAN = 'pro'
22
+
23
+ export default function Billing(): ReactNode {
24
+ const locale = useLocale()
25
+ const { data } = useSubscription<SubscriptionState>('app', 'billing.subscription')
26
+ const portal = useAction<{ returnUrl: string }, { url: string }>('app', 'billing.portalUrl')
27
+ const preview = useAction<{ plan?: string; quantity?: number }, ChangePreview>('app', 'billing.previewChange')
28
+ const change = useAction<{ plan: string }, ChangePreview>('app', 'billing.changePlan')
29
+
30
+ const [quote, setQuote] = useState<ChangePreview | null>(null)
31
+ const [changed, setChanged] = useState(false)
32
+ const sub = data?.subscription ?? null
33
+ const freeLabel = useT('billing.plan.free')
34
+ const busy = portal.pending || preview.pending || change.pending
35
+
36
+ const onManageCard = (): void => {
37
+ const here = typeof window === 'undefined' ? '/billing' : window.location.href
38
+ void portal.run({ returnUrl: here })
39
+ .then((r) => { if (r && typeof window !== 'undefined') window.location.assign(r.url) })
40
+ .catch(() => { /* surfaced via portal.error */ })
41
+ }
42
+
43
+ const onPreview = (): void => {
44
+ setChanged(false)
45
+ void preview.run({ plan: UPGRADE_PLAN }).then((q) => setQuote(q)).catch(() => setQuote(null))
46
+ }
47
+
48
+ const onConfirm = (): void => {
49
+ void change.run({ plan: UPGRADE_PLAN }).then(() => { setChanged(true); setQuote(null) }).catch(() => { /* change.error */ })
50
+ }
51
+
52
+ return (
53
+ <section>
54
+ <h1><T id="billing.heading" /></h1>
55
+
56
+ <dl className="facts">
57
+ <dt><T id="billing.plan.label" /></dt>
58
+ <dd>{sub?.plan ?? freeLabel}</dd>
59
+ <dt><T id="billing.status.label" /></dt>
60
+ <dd>{sub?.status ?? '—'}</dd>
61
+ <dt><T id="billing.seats.label" /></dt>
62
+ <dd>{sub?.quantity ?? 1}</dd>
63
+ </dl>
64
+
65
+ <div className="row" style={{ marginTop: '1.25rem' }}>
66
+ <button type="button" className="btn ghost" disabled={busy} onClick={onManageCard}>
67
+ <T id="billing.card.manage" />
68
+ </button>
69
+ </div>
70
+
71
+ <div className="paywall" style={{ marginTop: '1.5rem' }}>
72
+ <strong><T id="billing.change.title" /></strong>
73
+ <p className="muted"><T id="billing.change.body" /></p>
74
+ {quote ? (
75
+ <p role="status">
76
+ <T
77
+ id="billing.change.quote"
78
+ values={{ amount: formatMoney(Math.abs(quote.prorationMinor), quote.currency, locale) }}
79
+ />
80
+ </p>
81
+ ) : null}
82
+ {changed ? <p className="ok" role="status"><T id="billing.change.done" /></p> : null}
83
+ <div className="row">
84
+ {quote ? (
85
+ <button type="button" className="btn" disabled={busy} onClick={onConfirm}>
86
+ <T id={change.pending ? 'billing.pending' : 'billing.change.confirm'} />
87
+ </button>
88
+ ) : (
89
+ <button type="button" className="btn" disabled={busy} onClick={onPreview}>
90
+ <T id={preview.pending ? 'billing.pending' : 'billing.change.preview'} />
91
+ </button>
92
+ )}
93
+ </div>
94
+ </div>
95
+ </section>
96
+ )
97
+ }
@@ -0,0 +1,19 @@
1
+ // Error boundary scoped to the portal subtree. A render error (or a loader
2
+ // rejection that ISN'T a RedirectError / NotFoundError) bubbles here.
3
+ import type { ReactNode } from 'react'
4
+ import type { ErrorBoundaryProps } from '@voltro/web'
5
+ import { T } from '@voltro/i18n'
6
+
7
+ export default function PortalError({ error, reset }: ErrorBoundaryProps): ReactNode {
8
+ return (
9
+ <div className="dash">
10
+ <main>
11
+ <h1><T id="error.heading" /></h1>
12
+ <p className="muted">{String(error)}</p>
13
+ <p style={{ marginTop: '1rem' }}>
14
+ <button type="button" className="btn" onClick={reset}><T id="error.retry" /></button>
15
+ </p>
16
+ </main>
17
+ </div>
18
+ )
19
+ }
@@ -0,0 +1,45 @@
1
+ // Invoices — URL `/invoices`. Lists the tenant's invoice history live from
2
+ // `billing.invoices`. Each row's amount/status is shown; "View" / "PDF" link OUT
3
+ // to the provider's own hosted pages (`hostedUrl` / `pdfUrl`) — the portal never
4
+ // renders an invoice document itself.
5
+ import type { ReactNode } from 'react'
6
+ import type { PageMeta } from '@voltro/web'
7
+ import { useSubscription } from '@voltro/client'
8
+ import { T, useLocale } from '@voltro/i18n'
9
+ import { getCatalog } from '../../../locales/index'
10
+ import { formatMoney, type Invoice } from '../../../lib/api'
11
+
12
+ export const renderMode = 'ssr' as const
13
+
14
+ export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
15
+ title: getCatalog(locale)['meta.invoices.title'],
16
+ })
17
+
18
+ export default function Invoices(): ReactNode {
19
+ const locale = useLocale()
20
+ const { data } = useSubscription<{ invoices: ReadonlyArray<Invoice> }>('app', 'billing.invoices')
21
+ const invoices = data?.invoices ?? []
22
+
23
+ return (
24
+ <section>
25
+ <h1><T id="invoices.heading" /></h1>
26
+ {invoices.length === 0 ? (
27
+ <p className="empty"><T id="invoices.empty" /></p>
28
+ ) : (
29
+ <ul className="rows" style={{ marginTop: '1.25rem' }}>
30
+ {invoices.map((inv) => (
31
+ <li key={inv.providerInvoiceId}>
32
+ <span className="grow">
33
+ {inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}
34
+ </span>
35
+ <span>{formatMoney(inv.amountMinor, inv.currency, locale)}</span>
36
+ <span className="tag">{inv.status}</span>
37
+ {inv.hostedUrl ? <a href={inv.hostedUrl} target="_blank" rel="noopener noreferrer"><T id="invoices.view" /></a> : null}
38
+ {inv.pdfUrl ? <a href={inv.pdfUrl} target="_blank" rel="noopener noreferrer"><T id="invoices.pdf" /></a> : null}
39
+ </li>
40
+ ))}
41
+ </ul>
42
+ )}
43
+ </section>
44
+ )
45
+ }
@@ -0,0 +1,31 @@
1
+ // The portal 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 resolves permissively.
5
+
6
+ import { describe, expect, test, vi } from 'vitest'
7
+ import { RedirectError } from '@voltro/web'
8
+ import { loader } from './layout'
9
+ import type { Identity } from '../../lib/api'
10
+
11
+ const queryReturning = (identity: Identity) =>
12
+ vi.fn(async (_tag: string, _input: unknown) => identity as unknown)
13
+
14
+ describe('portal gate — loader', () => {
15
+ test('redirects an anonymous caller to /login', async () => {
16
+ const query = queryReturning({ type: 'anonymous', id: null, tenantId: null })
17
+ await expect(loader({ query } as never)).rejects.toBeInstanceOf(RedirectError)
18
+ expect(query).toHaveBeenCalledWith('session.me', {})
19
+ })
20
+
21
+ test('lets a signed-in user through and returns the tenant', async () => {
22
+ const query = queryReturning({ type: 'user', id: 'u_1', tenantId: 'acme' })
23
+ const data = await loader({ query } as never)
24
+ expect(data).toEqual({ tenantId: 'acme' })
25
+ })
26
+
27
+ test('client navigation (no server query fn) resolves without re-gating', async () => {
28
+ const data = await loader({ query: undefined } as never)
29
+ expect(data).toEqual({ tenantId: null })
30
+ })
31
+ })
@@ -0,0 +1,63 @@
1
+ // The portal shell — and the AUTH GATE. This layout wraps every page in the
2
+ // silent `(portal)` group, and its `loader` runs server-side BEFORE the pages
3
+ // render: it asks the api's `session.me` and throws RedirectError to /login for
4
+ // anyone not signed in. The pages under here are `renderMode: 'ssr'` precisely so
5
+ // this runs per request.
6
+ import type { ReactNode } from 'react'
7
+ import { RedirectError, useLoaderData, useLocation, type LoaderFn } from '@voltro/web'
8
+ import { T, useLocale, useT } from '@voltro/i18n'
9
+ import { LocaleSwitcher } from '@voltro/ui-shadcn'
10
+ import { APP_NAME } from '../../config'
11
+ import type { Identity } from '../../lib/api'
12
+
13
+ const LOCALES = [
14
+ { code: 'en', label: 'English' },
15
+ { code: 'de', label: 'Deutsch' },
16
+ ]
17
+
18
+ interface PortalData {
19
+ readonly tenantId: string | null
20
+ }
21
+
22
+ export const loader: LoaderFn<PortalData> = async ({ query }) => {
23
+ if (query) {
24
+ const me = await query<Identity>('session.me', {})
25
+ if (me.type !== 'user') {
26
+ throw new RedirectError('/login?from=/')
27
+ }
28
+ return { tenantId: me.tenantId }
29
+ }
30
+ return { tenantId: null }
31
+ }
32
+
33
+ export default function PortalLayout({ children }: { readonly children: ReactNode }): ReactNode {
34
+ useLoaderData<PortalData>()
35
+ const pathname = useLocation()
36
+ const locale = useLocale()
37
+ const langLabel = useT('lang.label')
38
+ const navItem = (href: string, label: ReactNode): ReactNode => (
39
+ <a href={href} aria-current={pathname === href ? 'page' : undefined}>{label}</a>
40
+ )
41
+
42
+ return (
43
+ <div className="dash">
44
+ <aside>
45
+ <div className="brand">{APP_NAME}</div>
46
+ <nav>
47
+ {navItem('/', <T id="nav.overview" />)}
48
+ {navItem('/billing', <T id="nav.billing" />)}
49
+ {navItem('/invoices', <T id="nav.invoices" />)}
50
+ {navItem('/api-keys', <T id="nav.apiKeys" />)}
51
+ {navItem('/profile', <T id="nav.profile" />)}
52
+ </nav>
53
+ </aside>
54
+ <main>
55
+ <div className="topbar">
56
+ <span className="spacer" />
57
+ <LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
58
+ </div>
59
+ {children}
60
+ </main>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,15 @@
1
+ // Scoped 404 for the portal subtree.
2
+ import type { ReactNode } from 'react'
3
+ import { T } from '@voltro/i18n'
4
+
5
+ export default function PortalNotFound(): ReactNode {
6
+ return (
7
+ <div className="dash">
8
+ <main>
9
+ <h1><T id="notFound.heading" /></h1>
10
+ <p className="muted"><T id="notFound.body" /></p>
11
+ <p style={{ marginTop: '1rem' }}><a href="/"><T id="notFound.back" /></a></p>
12
+ </main>
13
+ </div>
14
+ )
15
+ }
@@ -0,0 +1,39 @@
1
+ // Overview — URL `/`. A glance at the account: the current plan (live from
2
+ // `billing.subscription`), renewal date, and quick links into the deeper pages.
3
+ import type { ReactNode } from 'react'
4
+ import type { PageMeta } from '@voltro/web'
5
+ import { useSubscription } from '@voltro/client'
6
+ import { T, useT } from '@voltro/i18n'
7
+ import { getCatalog } from '../../locales'
8
+ import type { SubscriptionState } from '../../lib/api'
9
+
10
+ export const renderMode = 'ssr' as const
11
+
12
+ export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
13
+ title: getCatalog(locale)['meta.overview.title'],
14
+ })
15
+
16
+ export default function Overview(): ReactNode {
17
+ const { data } = useSubscription<SubscriptionState>('app', 'billing.subscription')
18
+ const sub = data?.subscription ?? null
19
+ const freeLabel = useT('billing.plan.free')
20
+
21
+ return (
22
+ <section>
23
+ <h1><T id="overview.heading" /></h1>
24
+ <dl className="facts">
25
+ <dt><T id="billing.plan.label" /></dt>
26
+ <dd>{sub?.plan ?? freeLabel}</dd>
27
+ <dt><T id="billing.status.label" /></dt>
28
+ <dd>{sub?.status ?? '—'}</dd>
29
+ <dt><T id="overview.renews" /></dt>
30
+ <dd>{sub?.currentPeriodEnd ? new Date(sub.currentPeriodEnd).toLocaleDateString() : '—'}</dd>
31
+ </dl>
32
+ <ul className="cards" style={{ marginTop: '1.5rem' }}>
33
+ <li className="card"><a href="/billing"><T id="nav.billing" /></a><div className="label"><T id="overview.link.billing" /></div></li>
34
+ <li className="card"><a href="/invoices"><T id="nav.invoices" /></a><div className="label"><T id="overview.link.invoices" /></div></li>
35
+ <li className="card"><a href="/api-keys"><T id="nav.apiKeys" /></a><div className="label"><T id="overview.link.apiKeys" /></div></li>
36
+ </ul>
37
+ </section>
38
+ )
39
+ }
@@ -0,0 +1,79 @@
1
+ // Profile — URL `/profile`. Shows the signed-in identity (from the gate's
2
+ // `session.me`, re-read here), a self-contained password-change (requests a reset
3
+ // link via the api's `/auth/password-reset` route), and sign out. Kept
4
+ // deliberately thin — the portal is account admin, not a settings sprawl.
5
+ import type { ReactNode } from 'react'
6
+ import { useEffect, useState, type FormEvent } from 'react'
7
+ import type { PageMeta } from '@voltro/web'
8
+ import { useAction } from '@voltro/client'
9
+ import { T, useT } from '@voltro/i18n'
10
+ import { getCatalog } from '../../../locales/index'
11
+ import type { Identity } from '../../../lib/api'
12
+
13
+ export const renderMode = 'ssr' as const
14
+
15
+ export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
16
+ title: getCatalog(locale)['meta.profile.title'],
17
+ })
18
+
19
+ const csrf = async (): Promise<string> => {
20
+ const body = (await fetch('/auth/csrf').then((r) => r.json())) as { csrfToken?: string }
21
+ return body.csrfToken ?? ''
22
+ }
23
+
24
+ export default function Profile(): ReactNode {
25
+ const me = useAction<Record<string, never>, Identity>('app', 'session.me')
26
+ const [ident, setIdent] = useState<Identity | null>(null)
27
+ const [email, setEmail] = useState('')
28
+ const [sent, setSent] = useState(false)
29
+ const emailLabel = useT('profile.cp.email')
30
+
31
+ useEffect(() => {
32
+ void me.run({}).then(setIdent).catch(() => setIdent(null))
33
+ }, []) // read the identity once on mount
34
+
35
+ const onChangePassword = (e: FormEvent): void => {
36
+ e.preventDefault()
37
+ void csrf()
38
+ .then((token) =>
39
+ fetch('/auth/password-reset', {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json', 'x-csrf-token': token },
42
+ body: JSON.stringify({ email }),
43
+ }),
44
+ )
45
+ .finally(() => setSent(true))
46
+ }
47
+
48
+ const signOut = (): void => {
49
+ void csrf()
50
+ .then((token) => fetch('/auth/sign-out', { method: 'POST', headers: { 'x-csrf-token': token } }))
51
+ .finally(() => window.location.assign('/login'))
52
+ }
53
+
54
+ return (
55
+ <section>
56
+ <h1><T id="profile.heading" /></h1>
57
+ <dl className="facts">
58
+ <dt><T id="profile.id" /></dt>
59
+ <dd><code>{ident?.id ?? '—'}</code></dd>
60
+ <dt><T id="profile.tenant" /></dt>
61
+ <dd><code>{ident?.tenantId ?? '—'}</code></dd>
62
+ </dl>
63
+
64
+ <h2 style={{ marginTop: '1.5rem' }}><T id="profile.cp.heading" /></h2>
65
+ {sent ? (
66
+ <p className="ok" role="status"><T id="profile.cp.sent" /></p>
67
+ ) : (
68
+ <form className="row" onSubmit={onChangePassword}>
69
+ <input type="email" required aria-label={emailLabel} placeholder={emailLabel} value={email} onChange={(e) => setEmail(e.target.value)} />
70
+ <button type="submit" className="btn ghost"><T id="profile.cp.submit" /></button>
71
+ </form>
72
+ )}
73
+
74
+ <div className="row" style={{ marginTop: '1.5rem' }}>
75
+ <button type="button" className="btn" onClick={signOut}><T id="profile.signOut" /></button>
76
+ </div>
77
+ </section>
78
+ )
79
+ }
@@ -0,0 +1,12 @@
1
+ // Root layout — wraps EVERY page (marketing + dashboard). Thin: it loads the
2
+ // stylesheet and lets each AREA layout ((marketing)/layout.tsx,
3
+ // dashboard/layout.tsx) supply its own chrome. Layouts nest, so a dashboard
4
+ // page is wrapped root → dashboard.
5
+ //
6
+ // Don't render <html>/<head>/<body> here — the framework shell owns them.
7
+ import type { ReactNode } from 'react'
8
+ import '../globals.css'
9
+
10
+ export default function RootLayout({ children }: { readonly children: ReactNode }): ReactNode {
11
+ return <>{children}</>
12
+ }
@@ -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') ?? '/'
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,6 @@
1
+ {
2
+ "id": "frontend-portal",
3
+ "kind": "web",
4
+ "summary": "A lean customer billing + account portal over the api-saas-starter backend: an SSR auth gate (session.me), an overview, a billing page (plan + payment method via billing.portalUrl + plan change via billing.previewChange/changePlan), a paginated invoice list (billing.invoices → provider hostedUrl/pdfUrl), an API-keys page over the framework's /v1/api-keys REST surface (degrades cleanly when not enabled), and a profile page. Bilingual (en/de).",
5
+ "tags": ["web", "frontend", "portal", "billing", "invoices", "api-keys", "account", "ssr"]
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "exclude": ["node_modules", "dist"]
5
+ }
@@ -0,0 +1,73 @@
1
+ # {{capProjectName}} {{capAppName}} — the SaaS dashboard
2
+
3
+ The frontend that closes the **signup → paywall → app** loop, consuming the
4
+ `api-saas-starter` api. Bilingual (en/de), auth-gated, live.
5
+
6
+ Scaffold both halves together:
7
+
8
+ ```bash
9
+ voltro init my-saas --api=api-saas-starter --web=frontend-saas
10
+ voltro dev # api on :4000, this app on its configured port
11
+ ```
12
+
13
+ ## The loop, as pages
14
+
15
+ | URL | What it does |
16
+ |---|---|
17
+ | `/` | Marketing landing (static, zero-JS) |
18
+ | `/login` | Sign-in / sign-up form → POST `/auth/*` (CSRF handled), sets the session cookie |
19
+ | `/dashboard` | **Gate** (SSR loader asks `session.me`, redirects anonymous → /login) + live projects list; creating the 4th trips the **paywall** |
20
+ | `/dashboard/billing` | Live `billing.subscription`; **upgrade** via `billing.startCheckout`, **add seats** via `billing.changeSeats` |
21
+ | `/dashboard/team` | Live `invites.list`; **invite** via `invites.create` (spends a seat) |
22
+
23
+ ## How the pieces connect
24
+
25
+ - **Auth gate** — `dashboard/layout.tsx` exports a `loader`. Server-side it calls
26
+ the api's `session.me` (forwarding the session cookie) and throws
27
+ `RedirectError('/login')` for an anonymous caller. The pages under it are
28
+ `renderMode: 'ssr'` so the gate runs per request.
29
+ - **Live reads** — `useSubscription('app', 'projects.list' | 'invites.list' |
30
+ 'billing.subscription')`. First paint is server-rendered; the WebSocket
31
+ upgrades it live.
32
+ - **The paywall** — `projects.create` / `invites.create` are entitlement-gated
33
+ on the api. When quota runs out the mutation rejects with a typed
34
+ `EntitlementExceeded`; the page branches on `isEntitlementExceeded(err)` (see
35
+ `src/lib/api.ts`) and shows the upgrade CTA — never a raw error string.
36
+ - **Checkout + seats** — `useAction('app', 'billing.startCheckout' |
37
+ 'billing.changeSeats')`. Under `voltro dev` the `mock` billing provider returns
38
+ a dev checkout URL; swap the api to `provider: 'stripe'` for real Stripe.
39
+
40
+ ## i18n
41
+
42
+ Cookie-driven (no `/<locale>` URL prefix): `<LocaleSwitcher>` writes `voltro:lang`
43
+ and reloads. Every string is a key in `src/locales/en.ts`, mirrored in `de.ts`
44
+ (`defineLocale<typeof en>()` fails the build on a missing key). Add a language
45
+ with a new code in `app.config.ts` + a `src/locales/<code>.ts`.
46
+
47
+ ## Files
48
+
49
+ ```
50
+ src/pages/
51
+ (marketing)/ silent route group → `/` and `/login`
52
+ page.tsx landing (static)
53
+ login/page.tsx real auth form → /auth/*
54
+ dashboard/
55
+ layout.tsx SSR auth gate (session.me) + sidebar shell
56
+ page.tsx projects list + create + PAYWALL branch
57
+ billing/page.tsx subscription + upgrade (checkout) + seats
58
+ team/page.tsx invites list + create
59
+ error.tsx / not-found.tsx
60
+ src/lib/api.ts local row types + isEntitlementExceeded()
61
+ src/locales/{en,de}.ts bilingual catalogs
62
+ ```
63
+
64
+ ## Anti-patterns
65
+
66
+ - **String-matching the paywall.** Branch on the typed `_tag ===
67
+ 'EntitlementExceeded'` (`isEntitlementExceeded`), not on a message — messages
68
+ are localized and change.
69
+ - **A `static` gated page.** The auth gate must run per request; a `static` page
70
+ can't redirect per-visitor and the build rejects a `RedirectError` there. Keep
71
+ the dashboard pages `renderMode: 'ssr'`.
72
+ - **Putting a secret in this app's env.** The browser bundle is public. Secrets
73
+ live on the api.