@voltro/cli 0.28.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 (280) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
  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-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
  12. package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
  13. package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
  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/serveCommand-BXl8mhZD.js +1489 -0
  25. package/dist/serveEntry.js +2 -2
  26. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  27. package/dist/startEntry.js +2 -2
  28. package/package.json +17 -17
  29. package/templates/AGENTS.md +2 -1
  30. package/templates/agent-docs/_index.md +2 -1
  31. package/templates/agent-docs/_manifest.json +10 -1
  32. package/templates/agent-docs/ai.md +135 -0
  33. package/templates/agent-docs/cli.md +124 -6
  34. package/templates/agent-docs/configuration.md +32 -0
  35. package/templates/agent-docs/data.md +282 -0
  36. package/templates/agent-docs/database/migrations.md +47 -0
  37. package/templates/agent-docs/database/misc.md +57 -0
  38. package/templates/agent-docs/database/schema.md +3 -1
  39. package/templates/agent-docs/internationalization.md +161 -1
  40. package/templates/agent-docs/local-first-mobile.md +414 -0
  41. package/templates/agent-docs/routing.md +93 -2
  42. package/templates/agent-docs/schema-driven-ui.md +12 -0
  43. package/templates/agent-docs/templates/apibackends.md +1 -1
  44. package/templates/agent-docs/whats-new.md +162 -181
  45. package/templates/agent-docs/workflows.md +11 -0
  46. package/templates/apps/api-ai/package.json +7 -7
  47. package/templates/apps/api-auth/package.json +8 -8
  48. package/templates/apps/api-backend/package.json +7 -7
  49. package/templates/apps/api-backend-deactivation/package.json +7 -7
  50. package/templates/apps/api-backend-mail/package.json +8 -8
  51. package/templates/apps/api-backend-mariadb/package.json +9 -9
  52. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  53. package/templates/apps/api-backend-sqlite/README.md +38 -0
  54. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  55. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  56. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  58. package/templates/apps/api-backend-sqlite/package.json +30 -0
  59. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  60. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  61. package/templates/apps/api-backend-sqlite/template.json +6 -0
  62. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  63. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  64. package/templates/apps/api-backend-storage/package.json +8 -8
  65. package/templates/apps/api-cms/README.md +104 -0
  66. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  67. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  68. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  69. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  70. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  71. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  72. package/templates/apps/api-cms/app.config.ts +61 -0
  73. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  74. package/templates/apps/api-cms/content/index.ts +18 -0
  75. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  76. package/templates/apps/api-cms/database/schema.ts +64 -0
  77. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  78. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  79. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  80. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  81. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  82. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  83. package/templates/apps/api-cms/package.json +32 -0
  84. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  85. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  86. package/templates/apps/api-cms/template.json +6 -0
  87. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  88. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  89. package/templates/apps/api-cms/tsconfig.json +5 -0
  90. package/templates/apps/api-data-advanced/package.json +8 -8
  91. package/templates/apps/api-durable/package.json +8 -8
  92. package/templates/apps/api-feature-flags/package.json +9 -9
  93. package/templates/apps/api-governance/package.json +8 -8
  94. package/templates/apps/api-kv/package.json +8 -8
  95. package/templates/apps/api-moderation/package.json +8 -8
  96. package/templates/apps/api-observability/package.json +8 -8
  97. package/templates/apps/api-ratelimit/package.json +8 -8
  98. package/templates/apps/api-rbac/package.json +8 -8
  99. package/templates/apps/api-rest/package.json +7 -7
  100. package/templates/apps/api-saas/package.json +11 -11
  101. package/templates/apps/api-saas-starter/README.md +103 -0
  102. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  103. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  104. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  105. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  106. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  107. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  108. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  109. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  110. package/templates/apps/api-saas-starter/package.json +32 -0
  111. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  112. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  113. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  114. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  115. package/templates/apps/api-saas-starter/template.json +6 -0
  116. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  117. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  118. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  119. package/templates/apps/api-search/package.json +8 -8
  120. package/templates/apps/api-status/README.md +88 -0
  121. package/templates/apps/api-status/app.config.ts +36 -0
  122. package/templates/apps/api-status/authz.ts +33 -0
  123. package/templates/apps/api-status/database/schema.ts +70 -0
  124. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  125. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  126. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  127. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  128. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  129. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  130. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  131. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  132. package/templates/apps/api-status/package.json +30 -0
  133. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  134. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  135. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  136. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  137. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  138. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  139. package/templates/apps/api-status/template.json +6 -0
  140. package/templates/apps/api-status/tests/status.test.ts +70 -0
  141. package/templates/apps/api-status/tsconfig.json +5 -0
  142. package/templates/apps/api-versioning/package.json +8 -8
  143. package/templates/apps/api-webhooks/package.json +9 -9
  144. package/templates/apps/changelog/package.json +6 -6
  145. package/templates/apps/edge-functions/package.json +2 -2
  146. package/templates/apps/frontend-admin/package.json +8 -8
  147. package/templates/apps/frontend-app/package.json +8 -8
  148. package/templates/apps/frontend-auth/README.md +78 -0
  149. package/templates/apps/frontend-auth/app.config.ts +34 -0
  150. package/templates/apps/frontend-auth/package.json +32 -0
  151. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  152. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  153. package/templates/apps/frontend-auth/src/config.ts +11 -0
  154. package/templates/apps/frontend-auth/src/globals.css +105 -0
  155. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  156. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  157. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  158. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  159. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  160. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  161. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  162. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  163. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  164. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  165. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  166. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  167. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  168. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  169. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  170. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  171. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  172. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  173. package/templates/apps/frontend-auth/template.json +6 -0
  174. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  175. package/templates/apps/frontend-blank/package.json +7 -7
  176. package/templates/apps/frontend-cms/README.md +47 -0
  177. package/templates/apps/frontend-cms/app.config.ts +37 -0
  178. package/templates/apps/frontend-cms/package.json +33 -0
  179. package/templates/apps/frontend-cms/src/config.ts +8 -0
  180. package/templates/apps/frontend-cms/src/globals.css +105 -0
  181. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  182. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  183. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  184. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  185. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  186. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  187. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  188. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  192. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  193. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  194. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  195. package/templates/apps/frontend-cms/template.json +6 -0
  196. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  197. package/templates/apps/frontend-contact/package.json +7 -7
  198. package/templates/apps/frontend-dashboard/package.json +7 -7
  199. package/templates/apps/frontend-docs/package.json +7 -7
  200. package/templates/apps/frontend-i18n/package.json +6 -6
  201. package/templates/apps/frontend-landing/package.json +7 -7
  202. package/templates/apps/frontend-portal/README.md +71 -0
  203. package/templates/apps/frontend-portal/app.config.ts +37 -0
  204. package/templates/apps/frontend-portal/package.json +32 -0
  205. package/templates/apps/frontend-portal/src/config.ts +8 -0
  206. package/templates/apps/frontend-portal/src/globals.css +93 -0
  207. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  208. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  209. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  210. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  211. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  212. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  213. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  214. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  223. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  224. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  225. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  226. package/templates/apps/frontend-portal/template.json +6 -0
  227. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  228. package/templates/apps/frontend-saas/README.md +73 -0
  229. package/templates/apps/frontend-saas/app.config.ts +46 -0
  230. package/templates/apps/frontend-saas/package.json +32 -0
  231. package/templates/apps/frontend-saas/src/config.ts +8 -0
  232. package/templates/apps/frontend-saas/src/globals.css +85 -0
  233. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  234. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  235. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  236. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  237. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  238. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  239. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  240. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  242. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  243. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  244. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  250. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  251. package/templates/apps/frontend-saas/template.json +6 -0
  252. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  253. package/templates/apps/frontend-spa/package.json +7 -7
  254. package/templates/apps/frontend-ssr/package.json +7 -7
  255. package/templates/apps/frontend-ssr-api/package.json +8 -8
  256. package/templates/apps/frontend-static-blog/package.json +6 -6
  257. package/templates/apps/frontend-status/README.md +51 -0
  258. package/templates/apps/frontend-status/app.config.ts +39 -0
  259. package/templates/apps/frontend-status/package.json +32 -0
  260. package/templates/apps/frontend-status/src/config.ts +7 -0
  261. package/templates/apps/frontend-status/src/globals.css +70 -0
  262. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  263. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  264. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  265. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  266. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  267. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  268. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  269. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  270. package/templates/apps/frontend-status/template.json +6 -0
  271. package/templates/apps/frontend-status/tsconfig.json +11 -0
  272. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  273. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  274. package/dist/apiBuild-DgBS9ayv.js +0 -2
  275. package/dist/dbCommand-DvguqlzF.js +0 -2
  276. package/dist/fileConventions-3bffWssN.js +0 -30
  277. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  278. package/dist/inspect-gt8bq-Tz.js +0 -2
  279. package/dist/manifestBuild-ifczArzr.js +0 -2
  280. package/dist/serveCommand-ZTn-dPFa.js +0 -1425
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ // Team — URL `/dashboard/team`. Lists the tenant's invites live
2
+ // (`invites.list`) and creates them (`invites.create`). Creating an invite
3
+ // spends a `seats` entitlement on the api, so on the free plan (1 seat) the
4
+ // FIRST invite trips the same typed `EntitlementExceeded` — we branch on it and
5
+ // point the user at the Billing page to add a seat.
6
+ import type { ReactNode } from 'react'
7
+ import { useState, type FormEvent } from 'react'
8
+ import type { PageMeta } from '@voltro/web'
9
+ import { useSubscription, useMutation } from '@voltro/client'
10
+ import { T, useT } from '@voltro/i18n'
11
+ import { getCatalog } from '../../../locales/index'
12
+ import { isEntitlementExceeded, type Invite } 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.team.title'],
18
+ })
19
+
20
+ const STATUS_KEY: Record<string, 'team.status.pending' | 'team.status.accepted' | 'team.status.revoked'> = {
21
+ pending: 'team.status.pending',
22
+ accepted: 'team.status.accepted',
23
+ revoked: 'team.status.revoked',
24
+ }
25
+
26
+ export default function Team(): ReactNode {
27
+ const { data: live } = useSubscription<ReadonlyArray<Invite>>('app', 'invites.list')
28
+ const { mutate, pending } = useMutation<{ email: string }, Invite>('app', 'invites.create')
29
+ const invites = live ?? []
30
+ const [email, setEmail] = useState('')
31
+ const [outOfSeats, setOutOfSeats] = useState(false)
32
+ const placeholder = useT('team.invite.placeholder')
33
+
34
+ const onInvite = (e: FormEvent): void => {
35
+ e.preventDefault()
36
+ const trimmed = email.trim()
37
+ if (trimmed === '') return
38
+ void mutate({ email: trimmed })
39
+ .then(() => { setEmail(''); setOutOfSeats(false) })
40
+ .catch((err: unknown) => { if (isEntitlementExceeded(err)) setOutOfSeats(true) })
41
+ }
42
+
43
+ return (
44
+ <section>
45
+ <h1><T id="team.heading" /></h1>
46
+
47
+ <form className="row" onSubmit={onInvite}>
48
+ <input
49
+ type="email" aria-label={placeholder} placeholder={placeholder}
50
+ value={email} onChange={(e) => setEmail(e.target.value)}
51
+ />
52
+ <button type="submit" className="btn" disabled={pending}>
53
+ <T id="team.invite.submit" />
54
+ </button>
55
+ </form>
56
+
57
+ {outOfSeats ? <p className="error-text" role="alert"><T id="team.paywall" /></p> : null}
58
+
59
+ {invites.length === 0 ? (
60
+ <p className="empty"><T id="team.empty" /></p>
61
+ ) : (
62
+ <ul className="rows" style={{ marginTop: '1.25rem' }}>
63
+ {invites.map((inv) => (
64
+ <li key={inv.id}>
65
+ <span>{inv.email}</span>
66
+ <span className="tag"><T id={STATUS_KEY[inv.status] ?? 'team.status.pending'} /></span>
67
+ </li>
68
+ ))}
69
+ </ul>
70
+ )}
71
+ </section>
72
+ )
73
+ }
@@ -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,6 @@
1
+ {
2
+ "id": "frontend-saas",
3
+ "kind": "web",
4
+ "summary": "The SaaS dashboard that closes the loop: a marketing landing, a real sign-up/sign-in form (POST /auth/*), an SSR auth gate that asks the api's session.me and RedirectErrors anonymous visitors to /login, a live projects list (useSubscription) whose create surfaces the typed EntitlementExceeded paywall, and billing (upgrade via billing.startCheckout, seats via billing.changeSeats) + team-invite pages. Pairs with the api-saas-starter api. Bilingual (en/de).",
5
+ "tags": ["web", "frontend", "saas", "auth", "billing", "paywall", "dashboard", "ssr", "useSubscription"]
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "exclude": ["node_modules", "dist"]
5
+ }
@@ -12,17 +12,17 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.28.0",
16
- "@voltro/client": "0.28.0",
17
- "@voltro/env": "0.28.0",
18
- "@voltro/i18n": "0.28.0",
19
- "@voltro/ui-shadcn": "0.28.0",
20
- "@voltro/web": "0.28.0",
15
+ "@voltro/cli": "0.29.0",
16
+ "@voltro/client": "0.29.0",
17
+ "@voltro/env": "0.29.0",
18
+ "@voltro/i18n": "0.29.0",
19
+ "@voltro/ui-shadcn": "0.29.0",
20
+ "@voltro/web": "0.29.0",
21
21
  "react": "^19.0.0",
22
22
  "react-dom": "^19.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/devtools": "0.28.0",
25
+ "@voltro/devtools": "0.29.0",
26
26
  "@types/react": "^19.0.0",
27
27
  "@types/react-dom": "^19.0.0",
28
28
  "typescript": "^6.0.3",
@@ -12,17 +12,17 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.28.0",
16
- "@voltro/client": "0.28.0",
17
- "@voltro/env": "0.28.0",
18
- "@voltro/i18n": "0.28.0",
19
- "@voltro/ui-shadcn": "0.28.0",
20
- "@voltro/web": "0.28.0",
15
+ "@voltro/cli": "0.29.0",
16
+ "@voltro/client": "0.29.0",
17
+ "@voltro/env": "0.29.0",
18
+ "@voltro/i18n": "0.29.0",
19
+ "@voltro/ui-shadcn": "0.29.0",
20
+ "@voltro/web": "0.29.0",
21
21
  "react": "^19.0.0",
22
22
  "react-dom": "^19.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/devtools": "0.28.0",
25
+ "@voltro/devtools": "0.29.0",
26
26
  "@types/react": "^19.0.0",
27
27
  "@types/react-dom": "^19.0.0",
28
28
  "typescript": "^6.0.3",
@@ -12,18 +12,18 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@{{projectName}}/api": "0.28.0",
16
- "@voltro/cli": "0.28.0",
17
- "@voltro/client": "0.28.0",
18
- "@voltro/env": "0.28.0",
19
- "@voltro/i18n": "0.28.0",
20
- "@voltro/ui-shadcn": "0.28.0",
21
- "@voltro/web": "0.28.0",
15
+ "@{{projectName}}/api": "0.29.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/client": "0.29.0",
18
+ "@voltro/env": "0.29.0",
19
+ "@voltro/i18n": "0.29.0",
20
+ "@voltro/ui-shadcn": "0.29.0",
21
+ "@voltro/web": "0.29.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/devtools": "0.28.0",
26
+ "@voltro/devtools": "0.29.0",
27
27
  "@types/react": "^19.0.0",
28
28
  "@types/react-dom": "^19.0.0",
29
29
  "typescript": "^6.0.3",
@@ -12,16 +12,16 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.28.0",
16
- "@voltro/client": "0.28.0",
17
- "@voltro/env": "0.28.0",
18
- "@voltro/i18n": "0.28.0",
19
- "@voltro/web": "0.28.0",
15
+ "@voltro/cli": "0.29.0",
16
+ "@voltro/client": "0.29.0",
17
+ "@voltro/env": "0.29.0",
18
+ "@voltro/i18n": "0.29.0",
19
+ "@voltro/web": "0.29.0",
20
20
  "react": "^19.0.0",
21
21
  "react-dom": "^19.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/devtools": "0.28.0",
24
+ "@voltro/devtools": "0.29.0",
25
25
  "@types/react": "^19.0.0",
26
26
  "@types/react-dom": "^19.0.0",
27
27
  "typescript": "^6.0.3",