create-ej-app 26.0.0 → 28.0.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 (357) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +79 -29
  3. package/package.json +2 -2
  4. package/template/api/.vscode/settings.json +1 -1
  5. package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
  6. package/template/api/CLAUDE.md +1 -0
  7. package/template/api/package.json +10 -10
  8. package/template/nextjs/.dockerignore +1 -0
  9. package/template/nextjs/AGENTS.md +260 -0
  10. package/template/nextjs/CLAUDE.md +1 -0
  11. package/template/nextjs/README.md +109 -0
  12. package/template/nextjs/bunfig.toml +3 -0
  13. package/template/nextjs/eslint.config.mjs +6 -5
  14. package/template/nextjs/gitignore +6 -1
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -39
  17. package/template/nextjs/public/logo-dark.svg +4 -0
  18. package/template/nextjs/public/logo.svg +4 -0
  19. package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
  20. package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  21. package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  22. package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  23. package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
  24. package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
  25. package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
  26. package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  27. package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  28. package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  29. package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  30. package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  31. package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
  32. package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
  33. package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
  34. package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
  35. package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  36. package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
  37. package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
  38. package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
  39. package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
  40. package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
  41. package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
  42. package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
  43. package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
  44. package/template/nextjs/src/app/error.tsx +37 -0
  45. package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  46. package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  47. package/template/nextjs/src/app/fonts/index.ts +60 -0
  48. package/template/nextjs/src/app/global-error.tsx +38 -0
  49. package/template/nextjs/src/app/globals.css +29 -0
  50. package/template/nextjs/src/app/layout.tsx +45 -18
  51. package/template/nextjs/src/app/not-found.tsx +25 -3
  52. package/template/nextjs/src/app/robots.ts +29 -0
  53. package/template/nextjs/src/app/sitemap.ts +21 -0
  54. package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
  55. package/template/nextjs/src/components/dashboard-header.tsx +70 -0
  56. package/template/nextjs/src/components/data-table/enum.ts +5 -0
  57. package/template/nextjs/src/components/data-table/features.ts +49 -0
  58. package/template/nextjs/src/components/data-table/index.tsx +961 -0
  59. package/template/nextjs/src/components/data-table/types.ts +76 -0
  60. package/template/nextjs/src/components/error.tsx +71 -0
  61. package/template/nextjs/src/components/loader.tsx +18 -2
  62. package/template/nextjs/src/components/logo.tsx +49 -0
  63. package/template/nextjs/src/components/navigation-search.tsx +140 -0
  64. package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
  65. package/template/nextjs/src/components/page-heading.tsx +38 -0
  66. package/template/nextjs/src/components/react-query-provider.tsx +41 -2
  67. package/template/nextjs/src/components/route-error.tsx +55 -0
  68. package/template/nextjs/src/components/screen-size.tsx +36 -0
  69. package/template/nextjs/src/components/site-footer.tsx +20 -4
  70. package/template/nextjs/src/components/theme-provider.tsx +48 -0
  71. package/template/nextjs/src/components/theme-toggle.tsx +58 -5
  72. package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
  73. package/template/nextjs/src/components/ui/avatar.tsx +112 -0
  74. package/template/nextjs/src/components/ui/badge.tsx +54 -0
  75. package/template/nextjs/src/components/ui/button.tsx +3 -1
  76. package/template/nextjs/src/components/ui/card.tsx +4 -4
  77. package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
  78. package/template/nextjs/src/components/ui/command.tsx +192 -0
  79. package/template/nextjs/src/components/ui/dialog.tsx +164 -0
  80. package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
  81. package/template/nextjs/src/components/ui/input-group.tsx +156 -0
  82. package/template/nextjs/src/components/ui/select.tsx +195 -0
  83. package/template/nextjs/src/components/ui/sheet.tsx +143 -0
  84. package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
  85. package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
  86. package/template/nextjs/src/components/ui/table.tsx +116 -0
  87. package/template/nextjs/src/components/ui/textarea.tsx +18 -0
  88. package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
  89. package/template/nextjs/src/components/user-menu.tsx +206 -0
  90. package/template/nextjs/src/db/common.ts +10 -0
  91. package/template/nextjs/src/db/index.ts +16 -6
  92. package/template/nextjs/src/db/relations.ts +21 -0
  93. package/template/nextjs/src/db/schema.ts +85 -46
  94. package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
  95. package/template/nextjs/src/emails/magic-link.tsx +15 -13
  96. package/template/nextjs/src/emails/test.tsx +11 -9
  97. package/template/nextjs/src/emails/welcome.tsx +12 -9
  98. package/template/nextjs/src/env.ts +1 -1
  99. package/template/nextjs/src/hooks/use-mobile.ts +14 -12
  100. package/template/nextjs/src/instrumentation.ts +20 -2
  101. package/template/nextjs/src/lib/actions.ts +55 -0
  102. package/template/nextjs/src/lib/admin-navigation.ts +43 -0
  103. package/template/nextjs/src/lib/auth-client.ts +20 -9
  104. package/template/nextjs/src/lib/auth-permissions.ts +61 -0
  105. package/template/nextjs/src/lib/auth.ts +33 -3
  106. package/template/nextjs/src/lib/callback-url.ts +28 -0
  107. package/template/nextjs/src/lib/constants.ts +21 -0
  108. package/template/nextjs/src/lib/delay.ts +10 -0
  109. package/template/nextjs/src/lib/email-service.ts +21 -0
  110. package/template/nextjs/src/lib/errors.ts +74 -0
  111. package/template/nextjs/src/lib/format-currency.ts +22 -24
  112. package/template/nextjs/src/lib/format-date.ts +69 -6
  113. package/template/nextjs/src/lib/nodemailer.ts +27 -3
  114. package/template/nextjs/src/lib/otel.ts +92 -2
  115. package/template/nextjs/src/lib/safe-action-client.ts +60 -9
  116. package/template/nextjs/src/lib/safe-action.ts +152 -16
  117. package/template/nextjs/src/lib/search.ts +16 -0
  118. package/template/nextjs/src/lib/seo.ts +7 -0
  119. package/template/nextjs/src/lib/siteConfig.ts +17 -1
  120. package/template/nextjs/src/lib/toast-message.ts +87 -1
  121. package/template/nextjs/src/lib/validations.ts +30 -0
  122. package/template/nextjs/src/proxy.ts +48 -0
  123. package/template/nextjs/src/scripts/reset-db.ts +22 -0
  124. package/template/nextjs/src/scripts/seed.ts +59 -8
  125. package/template/nextjs/src/scripts/test-mail.ts +28 -20
  126. package/template/nextjs/tests/admin/users.test.ts +64 -0
  127. package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
  128. package/template/nextjs/tests/db/seed.test.ts +49 -0
  129. package/template/nextjs/tests/helpers/action.ts +71 -0
  130. package/template/nextjs/tests/helpers/fixtures.ts +78 -0
  131. package/template/nextjs/tests/helpers/index.ts +2 -0
  132. package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
  133. package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
  134. package/template/nextjs/tests/lib/errors.test.ts +10 -0
  135. package/template/nextjs/tests/lib/search.test.ts +23 -0
  136. package/template/nextjs/tests/setup/pglite-db.ts +63 -0
  137. package/template/nextjs/tests/setup/preload.ts +95 -0
  138. package/template/nextjs/tsconfig.json +1 -0
  139. package/template/saas-starter/.dockerignore +1 -0
  140. package/template/saas-starter/.env.example +15 -15
  141. package/template/saas-starter/AGENTS.md +302 -0
  142. package/template/saas-starter/CLAUDE.md +1 -0
  143. package/template/saas-starter/README.md +124 -2
  144. package/template/saas-starter/bunfig.toml +3 -0
  145. package/template/saas-starter/eslint.config.mjs +6 -5
  146. package/template/saas-starter/gitignore +6 -1
  147. package/template/saas-starter/next.config.ts +19 -1
  148. package/template/saas-starter/package.json +50 -44
  149. package/template/saas-starter/public/logo-dark.svg +4 -0
  150. package/template/saas-starter/public/logo.svg +4 -0
  151. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  152. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  153. package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
  154. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
  155. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
  156. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  157. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  158. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
  159. package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
  160. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  161. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  162. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  163. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
  164. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  165. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  166. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  167. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  168. package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
  169. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  170. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  171. package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
  172. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  173. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  174. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
  175. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
  176. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
  177. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
  178. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
  179. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
  180. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
  181. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  182. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  188. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
  189. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
  190. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
  191. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  192. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
  193. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
  194. package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
  195. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
  196. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
  197. package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
  198. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  199. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  200. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  201. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  202. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  203. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
  204. package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
  205. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
  206. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
  207. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
  208. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  209. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  210. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  211. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  212. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  213. package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
  214. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  215. package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
  216. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
  217. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
  218. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  219. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  220. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  221. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  222. package/template/saas-starter/src/app/error.tsx +37 -0
  223. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  224. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  225. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  226. package/template/saas-starter/src/app/global-error.tsx +38 -0
  227. package/template/saas-starter/src/app/globals.css +29 -0
  228. package/template/saas-starter/src/app/layout.tsx +43 -19
  229. package/template/saas-starter/src/app/not-found.tsx +25 -3
  230. package/template/saas-starter/src/app/robots.ts +14 -10
  231. package/template/saas-starter/src/app/sitemap.ts +2 -1
  232. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  233. package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
  234. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  235. package/template/saas-starter/src/components/data-table/index.tsx +65 -64
  236. package/template/saas-starter/src/components/data-table/types.ts +5 -4
  237. package/template/saas-starter/src/components/error.tsx +45 -0
  238. package/template/saas-starter/src/components/file-upload.tsx +65 -26
  239. package/template/saas-starter/src/components/loader.tsx +18 -2
  240. package/template/saas-starter/src/components/logo.tsx +49 -0
  241. package/template/saas-starter/src/components/navigation-search.tsx +2 -2
  242. package/template/saas-starter/src/components/page-heading.tsx +6 -0
  243. package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
  244. package/template/saas-starter/src/components/route-error.tsx +55 -0
  245. package/template/saas-starter/src/components/screen-size.tsx +36 -0
  246. package/template/saas-starter/src/components/site-footer.tsx +20 -4
  247. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  248. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  249. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  250. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  251. package/template/saas-starter/src/components/theme-provider.tsx +48 -0
  252. package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
  253. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  254. package/template/saas-starter/src/components/ui/badge.tsx +6 -1
  255. package/template/saas-starter/src/components/ui/button.tsx +2 -0
  256. package/template/saas-starter/src/components/ui/command.tsx +1 -1
  257. package/template/saas-starter/src/components/user-menu.tsx +147 -24
  258. package/template/saas-starter/src/db/common.ts +33 -1
  259. package/template/saas-starter/src/db/enums.ts +24 -0
  260. package/template/saas-starter/src/db/index.ts +14 -5
  261. package/template/saas-starter/src/db/relations.ts +40 -3
  262. package/template/saas-starter/src/db/schema.ts +186 -35
  263. package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
  264. package/template/saas-starter/src/emails/invitation.tsx +34 -21
  265. package/template/saas-starter/src/emails/magic-link.tsx +15 -13
  266. package/template/saas-starter/src/emails/test.tsx +11 -9
  267. package/template/saas-starter/src/emails/welcome.tsx +12 -9
  268. package/template/saas-starter/src/env.ts +1 -1
  269. package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
  270. package/template/saas-starter/src/instrumentation.ts +20 -2
  271. package/template/saas-starter/src/lib/actions.ts +34 -0
  272. package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
  273. package/template/saas-starter/src/lib/app-navigation.ts +45 -41
  274. package/template/saas-starter/src/lib/auth-client.ts +8 -3
  275. package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
  276. package/template/saas-starter/src/lib/auth.ts +122 -21
  277. package/template/saas-starter/src/lib/billing.ts +59 -24
  278. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  279. package/template/saas-starter/src/lib/constants.ts +78 -9
  280. package/template/saas-starter/src/lib/delay.ts +10 -0
  281. package/template/saas-starter/src/lib/email-service.ts +30 -1
  282. package/template/saas-starter/src/lib/errors.ts +74 -0
  283. package/template/saas-starter/src/lib/format-currency.ts +22 -24
  284. package/template/saas-starter/src/lib/format-date.ts +69 -6
  285. package/template/saas-starter/src/lib/image-url.ts +54 -0
  286. package/template/saas-starter/src/lib/nodemailer.ts +27 -3
  287. package/template/saas-starter/src/lib/organization-access.ts +56 -76
  288. package/template/saas-starter/src/lib/otel.ts +92 -2
  289. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  290. package/template/saas-starter/src/lib/plans.ts +40 -13
  291. package/template/saas-starter/src/lib/rbac.ts +44 -0
  292. package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
  293. package/template/saas-starter/src/lib/safe-action.ts +137 -17
  294. package/template/saas-starter/src/lib/search.ts +16 -0
  295. package/template/saas-starter/src/lib/seo.ts +7 -0
  296. package/template/saas-starter/src/lib/siteConfig.ts +17 -1
  297. package/template/saas-starter/src/lib/storage.ts +176 -46
  298. package/template/saas-starter/src/lib/stripe.ts +4 -1
  299. package/template/saas-starter/src/lib/toast-message.ts +87 -1
  300. package/template/saas-starter/src/lib/validations.ts +85 -2
  301. package/template/saas-starter/src/proxy.ts +48 -0
  302. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  303. package/template/saas-starter/src/scripts/seed.ts +82 -8
  304. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  305. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
  306. package/template/saas-starter/src/scripts/test-mail.ts +28 -20
  307. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  308. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  309. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  310. package/template/saas-starter/tests/helpers/action.ts +71 -0
  311. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  312. package/template/saas-starter/tests/helpers/index.ts +2 -0
  313. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  314. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  315. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  316. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  317. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  318. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  319. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  320. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  321. package/template/saas-starter/tests/setup/preload.ts +108 -0
  322. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  323. package/template/saas-starter/tsconfig.json +1 -0
  324. package/cli.js +0 -147
  325. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  326. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  327. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  328. package/template/nextjs/terraform/backend-service.tf +0 -13
  329. package/template/nextjs/terraform/cleanup.sh +0 -9
  330. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  331. package/template/nextjs/terraform/cloudrun.tf +0 -68
  332. package/template/nextjs/terraform/database.tf +0 -60
  333. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  334. package/template/nextjs/terraform/gitignore +0 -4
  335. package/template/nextjs/terraform/ip-address.tf +0 -11
  336. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  337. package/template/nextjs/terraform/network.tf +0 -21
  338. package/template/nextjs/terraform/pre.sh +0 -3
  339. package/template/nextjs/terraform/providers.tf +0 -18
  340. package/template/nextjs/terraform/secret.txt +0 -11
  341. package/template/nextjs/terraform/setup.sh +0 -25
  342. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  343. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  344. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  345. package/template/nextjs/terraform/terraform.tfvars +0 -4
  346. package/template/nextjs/terraform/url-map.tf +0 -23
  347. package/template/nextjs/terraform/variables.tf +0 -21
  348. package/template/saas-starter/next-env.d.ts +0 -6
  349. package/template/saas-starter/src/actions/uploads.ts +0 -28
  350. package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
  351. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
  352. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
  353. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
  354. package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
  355. package/template/saas-starter/src/app/proxy.ts +0 -28
  356. package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
  357. package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
@@ -4,150 +4,125 @@ import { zodResolver } from '@hookform/resolvers/zod'
4
4
  import { Loader2 } from 'lucide-react'
5
5
  import { useRouter } from 'next/navigation'
6
6
  import { useState } from 'react'
7
- import { useForm } from 'react-hook-form'
7
+ import { Controller, useForm } from 'react-hook-form'
8
8
  import { z } from 'zod'
9
- import { updateOrganizationLogoAction } from '~/app/(app)/actions/organization'
9
+ import { updateOrganizationAction } from '~/app/(app)/actions/organizations'
10
+ import { organizationDetailsSchema } from '~/app/(app)/actions/organizations.validation'
10
11
  import { FileUpload } from '~/components/file-upload'
11
12
  import { Button } from '~/components/ui/button'
12
13
  import {
13
14
  Field,
14
- FieldContent,
15
15
  FieldDescription,
16
16
  FieldError,
17
- FieldTitle
17
+ FieldLabel
18
18
  } from '~/components/ui/field'
19
19
  import { Input } from '~/components/ui/input'
20
- import { organization } from '~/lib/auth-client'
21
20
  import { useSafeActionMutation } from '~/lib/safe-action-client'
22
- import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
23
- import { stringValidation } from '~/lib/validations'
21
+ import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
24
22
 
25
- const schema = z.object({
26
- name: stringValidation,
27
- slug: z
28
- .string()
29
- .trim()
30
- .min(1, 'Slug is required')
31
- .max(255)
32
- .regex(
33
- /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
34
- 'Use lowercase letters, numbers and hyphens only'
35
- )
36
- })
37
-
38
- type Form = z.infer<typeof schema>
23
+ type Form = z.infer<typeof organizationDetailsSchema>
39
24
 
40
25
  export function GeneralSettingsForm({
41
26
  orgId,
42
27
  defaultName,
43
28
  defaultSlug,
44
- currentLogoUrl,
45
- currentLogoKey
29
+ currentLogoKey,
30
+ currentLogoUrl
46
31
  }: {
47
32
  orgId: string
48
33
  defaultName: string
49
34
  defaultSlug: string
50
- currentLogoUrl: string | null
35
+ /** Stored logo value, sent back unchanged when you keep the logo. */
51
36
  currentLogoKey: string | null
37
+ /** Renderable URL for the stored logo, from `resolveImageUrl`. */
38
+ currentLogoUrl: string | null
52
39
  }) {
53
40
  const router = useRouter()
54
41
  const [logo, setLogo] = useState<string | null>(currentLogoKey)
55
- const [submitting, setSubmitting] = useState(false)
56
- const { mutateAsync: updateOrgLogo } = useSafeActionMutation(
57
- updateOrganizationLogoAction
58
- )
59
42
 
60
- const {
61
- register,
62
- handleSubmit,
63
- formState: { errors, isDirty }
64
- } = useForm<Form>({
65
- resolver: zodResolver(schema),
43
+ const form = useForm<Form>({
44
+ resolver: zodResolver(organizationDetailsSchema),
66
45
  defaultValues: { name: defaultName, slug: defaultSlug }
67
46
  })
68
47
 
69
- const logoChanged = logo !== currentLogoKey
70
-
71
- async function onSubmit(values: Form) {
72
- setSubmitting(true)
73
-
74
- const { error } = await organization.update({
75
- organizationId: orgId,
76
- data: {
77
- name: values.name,
78
- slug: values.slug,
79
- logo: logo ?? undefined
80
- }
81
- })
82
-
83
- if (error) {
84
- toastErrorMessage(error.message ?? 'Failed to update organization')
85
- setSubmitting(false)
86
- return
87
- }
88
-
89
- if (logoChanged && logo) {
90
- await updateOrgLogo({
91
- organizationId: orgId,
92
- key: logo,
93
- oldKey: currentLogoKey
94
- })
95
- }
48
+ const save = useSafeActionMutation(updateOrganizationAction, {
49
+ onSuccess: (_, values) => {
50
+ toastSuccessMessage('Organization updated')
51
+ form.reset({ name: values.name, slug: values.slug })
52
+ router.refresh()
53
+ },
54
+ onError: error =>
55
+ toastActionError(error, 'Your changes were not saved. Try again.')
56
+ })
96
57
 
97
- toastSuccessMessage('Organization updated')
98
- setSubmitting(false)
99
- router.refresh()
100
- }
58
+ const logoChanged = logo !== currentLogoKey
101
59
 
102
60
  return (
103
- <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
104
- <Field orientation="vertical">
105
- <FieldContent>
106
- <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
107
- Organization name
108
- </FieldTitle>
109
- <Input {...register('name')} aria-invalid={!!errors.name} />
110
- <FieldError errors={[errors.name]} className="mt-1.5 text-[13px]" />
111
- </FieldContent>
112
- </Field>
113
-
114
- <Field orientation="vertical">
115
- <FieldContent>
116
- <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
117
- Slug
118
- </FieldTitle>
119
- <Input {...register('slug')} aria-invalid={!!errors.slug} />
120
- <FieldDescription className="mt-1.5 text-[13px]">
121
- A unique identifier used in links.
122
- </FieldDescription>
123
- <FieldError errors={[errors.slug]} className="mt-1.5 text-[13px]" />
124
- </FieldContent>
125
- </Field>
61
+ <form
62
+ onSubmit={form.handleSubmit(values =>
63
+ save.mutate({ ...values, organizationId: orgId, logo: logo ?? '' })
64
+ )}
65
+ className="flex flex-col gap-6"
66
+ >
67
+ <Controller
68
+ control={form.control}
69
+ name="name"
70
+ render={({ field, fieldState }) => (
71
+ <Field data-invalid={fieldState.invalid}>
72
+ <FieldLabel htmlFor={field.name}>Organization name</FieldLabel>
73
+ <Input
74
+ id={field.name}
75
+ aria-invalid={fieldState.invalid}
76
+ {...field}
77
+ />
78
+ <FieldError
79
+ errors={fieldState.error ? [fieldState.error] : undefined}
80
+ />
81
+ </Field>
82
+ )}
83
+ />
126
84
 
127
- <Field orientation="vertical">
128
- <FieldContent>
129
- <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
130
- Logo
131
- </FieldTitle>
132
- <div className="w-32">
133
- <FileUpload
134
- organizationId={orgId}
135
- accept="image/*"
136
- onClientUploadFinish={setLogo}
137
- currentUrl={currentLogoUrl}
138
- sizes="128px"
85
+ <Controller
86
+ control={form.control}
87
+ name="slug"
88
+ render={({ field, fieldState }) => (
89
+ <Field data-invalid={fieldState.invalid}>
90
+ <FieldLabel htmlFor={field.name}>Slug</FieldLabel>
91
+ <Input
92
+ id={field.name}
93
+ aria-invalid={fieldState.invalid}
94
+ {...field}
139
95
  />
140
- </div>
141
- </FieldContent>
96
+ <FieldDescription>
97
+ Identifies the organization in links.
98
+ </FieldDescription>
99
+ <FieldError
100
+ errors={fieldState.error ? [fieldState.error] : undefined}
101
+ />
102
+ </Field>
103
+ )}
104
+ />
105
+
106
+ <Field>
107
+ <FieldLabel>Logo</FieldLabel>
108
+ <div className="w-32">
109
+ <FileUpload
110
+ organizationId={orgId}
111
+ accept="image/*"
112
+ onClientUploadFinish={setLogo}
113
+ currentUrl={currentLogoUrl}
114
+ sizes="128px"
115
+ />
116
+ </div>
142
117
  </Field>
143
118
 
144
119
  <div>
145
120
  <Button
146
121
  type="submit"
147
- disabled={submitting || (!isDirty && !logoChanged)}
122
+ disabled={save.isPending || (!form.formState.isDirty && !logoChanged)}
148
123
  >
149
- {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
150
- Save changes
124
+ {save.isPending && <Loader2 className="animate-spin" />}
125
+ Save Changes
151
126
  </Button>
152
127
  </div>
153
128
  </form>
@@ -18,14 +18,24 @@ import {
18
18
  } from '~/components/ui/card'
19
19
  import { Label } from '~/components/ui/label'
20
20
  import { Switch } from '~/components/ui/switch'
21
+ import { TRIAL_DAYS } from '~/lib/constants'
21
22
  import { formatCurrency } from '~/lib/format-currency'
22
- import { Plan } from '~/lib/plans'
23
23
  import { useSafeActionMutation } from '~/lib/safe-action-client'
24
- import { toastErrorMessage } from '~/lib/toast-message'
24
+ import { toastActionError } from '~/lib/toast-message'
25
25
 
26
26
  type Interval = 'monthly' | 'annual'
27
+ type PaidPlanId = 'pro' | 'ultimate'
27
28
 
28
- const ACTIVE_STATUSES = ['trialing', 'active', 'past_due']
29
+ type PlanPrice = { amount: number; currency: string }
30
+
31
+ /** Public plan data from `getBillingOverview`. */
32
+ type PlanView = {
33
+ id: 'free' | PaidPlanId
34
+ name: string
35
+ maxMembers: number
36
+ canUseTrial: boolean
37
+ prices: { monthly: PlanPrice; annual: PlanPrice } | null
38
+ }
29
39
 
30
40
  const annualSavingsPercent = (monthly: number, annual: number) => {
31
41
  const fullYear = monthly * 12
@@ -37,58 +47,48 @@ export function PlansSection({
37
47
  orgId,
38
48
  plans,
39
49
  currentPlanId,
40
- currentStatus,
50
+ hasActiveSub,
41
51
  hasBillingEmail,
42
52
  canStartTrial
43
53
  }: {
44
54
  orgId: string
45
- plans: Plan[]
46
- currentPlanId: 'free' | 'pro' | 'ultimate'
47
- currentStatus: string
55
+ plans: PlanView[]
56
+ currentPlanId: PlanView['id']
57
+ /** A Stripe subscription still exists, so changes go through the portal. */
58
+ hasActiveSub: boolean
48
59
  hasBillingEmail: boolean
49
60
  canStartTrial: boolean
50
61
  }) {
51
62
  const [interval, setInterval] = useState<Interval>('monthly')
52
63
  const [pending, setPending] = useState<string | null>(null)
53
64
 
54
- const hasActiveSub = ACTIVE_STATUSES.includes(currentStatus)
55
-
56
- const { mutateAsync: createCheckout } = useSafeActionMutation(
57
- createCheckoutSessionAction
65
+ // Stripe hosts both pages, so success is a full-page redirect.
66
+ const redirectOptions = {
67
+ onSuccess: (data: { url: string } | undefined) => {
68
+ if (data?.url) window.location.assign(data.url)
69
+ },
70
+ onError: (error: Error) => {
71
+ toastActionError(error, 'Stripe did not open. Try again.')
72
+ setPending(null)
73
+ }
74
+ }
75
+ const checkout = useSafeActionMutation(
76
+ createCheckoutSessionAction,
77
+ redirectOptions
58
78
  )
59
- const { mutateAsync: createPortal } = useSafeActionMutation(
60
- createPortalSessionAction
79
+ const portal = useSafeActionMutation(
80
+ createPortalSessionAction,
81
+ redirectOptions
61
82
  )
62
83
 
63
- async function goToPortal(key: string) {
84
+ function goToPortal(key: string) {
64
85
  setPending(key)
65
- try {
66
- const result = await createPortal({ organizationId: orgId })
67
- if (result?.url) window.location.assign(result.url)
68
- } catch (error) {
69
- toastErrorMessage(
70
- error instanceof Error ? error.message : 'Failed to open billing portal'
71
- )
72
- setPending(null)
73
- }
86
+ portal.mutate({ organizationId: orgId })
74
87
  }
75
88
 
76
- async function goToCheckout(plan: 'pro' | 'ultimate', withTrial: boolean) {
89
+ function goToCheckout(plan: PaidPlanId, withTrial: boolean) {
77
90
  setPending(plan)
78
- try {
79
- const result = await createCheckout({
80
- organizationId: orgId,
81
- plan,
82
- interval,
83
- withTrial
84
- })
85
- if (result?.url) window.location.assign(result.url)
86
- } catch (error) {
87
- toastErrorMessage(
88
- error instanceof Error ? error.message : 'Failed to start checkout'
89
- )
90
- setPending(null)
91
- }
91
+ checkout.mutate({ organizationId: orgId, plan, interval, withTrial })
92
92
  }
93
93
 
94
94
  return (
@@ -100,19 +100,15 @@ export function PlansSection({
100
100
  onClick={() => goToPortal('manage')}
101
101
  disabled={pending !== null}
102
102
  >
103
- {pending === 'manage' && (
104
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
105
- )}
106
- Manage billing
103
+ {pending === 'manage' && <Loader2 className="animate-spin" />}
104
+ Manage Billing
107
105
  </Button>
108
106
  ) : (
109
107
  <span />
110
108
  )}
111
109
 
112
110
  <div className="flex items-center gap-2">
113
- <Label htmlFor="interval-toggle" className="text-[13px]">
114
- Monthly
115
- </Label>
111
+ <Label htmlFor="interval-toggle">Monthly</Label>
116
112
  <Switch
117
113
  id="interval-toggle"
118
114
  checked={interval === 'annual'}
@@ -120,49 +116,48 @@ export function PlansSection({
120
116
  setInterval(checked ? 'annual' : 'monthly')
121
117
  }
122
118
  />
123
- <Label htmlFor="interval-toggle" className="text-[13px]">
124
- Annual
125
- </Label>
119
+ <Label htmlFor="interval-toggle">Annual</Label>
126
120
  </div>
127
121
  </div>
128
122
 
129
123
  <div className="grid gap-4 md:grid-cols-3">
130
124
  {plans.map(plan => {
131
125
  const isCurrent = plan.id === currentPlanId
132
- const price = plan.stripePrice?.[interval]
126
+ const price = plan.prices?.[interval]
133
127
  const savings =
134
- plan.stripePrice && interval === 'annual'
128
+ plan.prices && interval === 'annual'
135
129
  ? annualSavingsPercent(
136
- plan.stripePrice.monthly.amount,
137
- plan.stripePrice.annual.amount
130
+ plan.prices.monthly.amount,
131
+ plan.prices.annual.amount
138
132
  )
139
133
  : 0
140
134
 
141
- const isPaid = plan.id !== 'free'
142
- const offerTrial = Boolean(plan.canUseTrial) && canStartTrial
135
+ const paidPlanId = plan.id === 'free' ? null : plan.id
136
+ const offerTrial = plan.canUseTrial && canStartTrial
143
137
  const isPending = pending === plan.id
144
138
 
145
139
  const onClick = isCurrent
146
140
  ? undefined
147
141
  : hasActiveSub
148
142
  ? () => goToPortal(plan.id)
149
- : isPaid
150
- ? () => goToCheckout(plan.id as 'pro' | 'ultimate', offerTrial)
143
+ : paidPlanId
144
+ ? () => goToCheckout(paidPlanId, offerTrial)
151
145
  : undefined
152
146
 
153
147
  const label = isCurrent
154
- ? 'Current plan'
148
+ ? 'Current Plan'
155
149
  : hasActiveSub
156
- ? plan.id === 'free'
157
- ? 'Cancel plan'
158
- : `Switch to ${plan.name}`
159
- : isPaid
150
+ ? paidPlanId
151
+ ? `Switch to ${plan.name}`
152
+ : 'Cancel Plan'
153
+ : paidPlanId
160
154
  ? offerTrial
161
- ? 'Start 7-day free trial'
155
+ ? `Start ${TRIAL_DAYS}-Day Free Trial`
162
156
  : `Upgrade to ${plan.name}`
163
- : 'Current plan'
157
+ : 'Current Plan'
164
158
 
165
- const needsBillingEmail = !hasActiveSub && isPaid && !hasBillingEmail
159
+ const needsBillingEmail =
160
+ !hasActiveSub && paidPlanId !== null && !hasBillingEmail
166
161
 
167
162
  return (
168
163
  <Card key={plan.id} className={isCurrent ? 'border-primary' : ''}>
@@ -180,23 +175,24 @@ export function PlansSection({
180
175
  <CardContent className="space-y-2">
181
176
  <div className="flex items-baseline gap-1">
182
177
  <span className="text-2xl font-bold tracking-tight">
183
- {price
184
- ? formatCurrency(price.currency, price.amount / 100)
185
- : '$0'}
178
+ {formatCurrency(
179
+ price?.currency ?? 'USD',
180
+ (price?.amount ?? 0) / 100
181
+ )}
186
182
  </span>
187
183
  <span className="text-muted-foreground text-sm">
188
184
  /{interval === 'annual' ? 'year' : 'month'}
189
185
  </span>
190
186
  </div>
191
187
  {savings > 0 && (
192
- <p className="text-muted-foreground text-[13px]">
193
- Save {savings}% vs monthly
188
+ <p className="text-muted-foreground text-sm">
189
+ Save {savings}% compared with monthly
194
190
  </p>
195
191
  )}
196
192
  {plan.canUseTrial && (
197
- <p className="text-muted-foreground flex items-center gap-1.5 text-[13px]">
198
- <Check className="size-3.5" /> 7-day free trial, card
199
- required
193
+ <p className="text-muted-foreground flex items-center gap-1.5 text-sm">
194
+ <Check className="size-3.5" /> {TRIAL_DAYS}-day free trial,
195
+ card required
200
196
  </p>
201
197
  )}
202
198
  </CardContent>
@@ -205,21 +201,15 @@ export function PlansSection({
205
201
  <Button
206
202
  className="w-full"
207
203
  variant={isCurrent ? 'outline' : 'default'}
208
- disabled={
209
- isCurrent ||
210
- needsBillingEmail ||
211
- (pending !== null && !isPending)
212
- }
204
+ disabled={isCurrent || needsBillingEmail || pending !== null}
213
205
  onClick={onClick}
214
206
  >
215
- {isPending && (
216
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
217
- )}
207
+ {isPending && <Loader2 className="animate-spin" />}
218
208
  {label}
219
209
  </Button>
220
210
  {needsBillingEmail && (
221
- <p className="text-muted-foreground text-center text-[12px]">
222
- Set a billing email first
211
+ <p className="text-muted-foreground text-center text-xs">
212
+ Add a billing email below first
223
213
  </p>
224
214
  )}
225
215
  </CardFooter>
@@ -1,88 +1,84 @@
1
1
  import { CreditCard } from 'lucide-react'
2
- import { redirect } from 'next/navigation'
2
+ import { notFound } from 'next/navigation'
3
+ import { getBillingOverview } from '~/app/(app)/actions/billing'
3
4
  import { PageHeading } from '~/components/page-heading'
4
5
  import { Badge } from '~/components/ui/badge'
5
6
  import { Separator } from '~/components/ui/separator'
6
- import { canManageOrganization } from '~/lib/app-navigation'
7
7
  import { formatDate } from '~/lib/format-date'
8
- import { getUserMembershipCached } from '~/lib/organization-access'
9
- import { getOrganizationPlanCached } from '~/lib/organization-plan-limits'
10
- import { PLANS } from '~/lib/plans'
11
8
  import { BillingEmailForm } from '../_components/billing-email-form'
12
9
  import { PlansSection } from '../_components/plans-section'
13
10
 
14
- const STATUS_LABELS: Record<
11
+ const STATUS_BADGES: Record<
15
12
  string,
16
13
  {
17
14
  label: string
18
- variant: 'default' | 'secondary' | 'destructive' | 'outline'
15
+ variant: 'secondary' | 'success' | 'info' | 'warning' | 'destructive'
19
16
  }
20
17
  > = {
21
18
  free: { label: 'Free', variant: 'secondary' },
22
- trialing: { label: 'Trialing', variant: 'default' },
23
- active: { label: 'Active', variant: 'default' },
24
- past_due: { label: 'Past due', variant: 'destructive' },
25
- canceled: { label: 'Canceled', variant: 'outline' },
19
+ trialing: { label: 'Trialing', variant: 'info' },
20
+ active: { label: 'Active', variant: 'success' },
21
+ past_due: { label: 'Past Due', variant: 'destructive' },
22
+ canceled: { label: 'Canceled', variant: 'secondary' },
26
23
  unpaid: { label: 'Unpaid', variant: 'destructive' },
27
- paused: { label: 'Paused', variant: 'outline' },
28
- incomplete: { label: 'Incomplete', variant: 'outline' },
29
- incomplete_expired: { label: 'Incomplete', variant: 'outline' }
24
+ paused: { label: 'Paused', variant: 'warning' },
25
+ incomplete: { label: 'Incomplete', variant: 'warning' },
26
+ incomplete_expired: { label: 'Expired', variant: 'secondary' }
30
27
  }
31
28
 
32
- export default async function Page({
29
+ export default async function BillingPage({
33
30
  params
34
31
  }: {
35
32
  params: Promise<{ orgId: string }>
36
33
  }) {
37
34
  const { orgId } = await params
35
+ const overview = (await getBillingOverview(orgId))?.data
38
36
 
39
- const membership = await getUserMembershipCached(orgId)
40
- if (!membership || !canManageOrganization(membership.role)) {
41
- return redirect(`/app/${orgId}/dashboard`)
37
+ if (!overview) {
38
+ return notFound()
42
39
  }
43
40
 
44
- const { plan, sub } = await getOrganizationPlanCached(orgId)
45
-
46
- const status = STATUS_LABELS[sub.status] ?? {
47
- label: sub.status,
48
- variant: 'outline' as const
41
+ const { currentPlan, subscription, plans } = overview
42
+ const status = STATUS_BADGES[subscription.status] ?? {
43
+ label: subscription.status,
44
+ variant: 'secondary' as const
49
45
  }
50
- const canStartTrial = sub.status === 'free' && !sub.hasUsedTrial
51
- const trialEndsAt = formatDate(sub.trialEndsAt)
52
- const renewsAt = formatDate(sub.currentPeriodEnd)
46
+ const trialEndsAt = formatDate(subscription.trialEndsAt)
47
+ const renewsAt = formatDate(subscription.currentPeriodEnd)
53
48
 
54
49
  return (
55
50
  <div className="mx-auto w-full max-w-3xl space-y-8">
56
51
  <PageHeading
57
52
  title="Billing"
58
- description="Manage your organization's plan and billing details."
53
+ description="Choose a plan and manage payment details."
59
54
  icon={CreditCard}
60
55
  />
61
56
 
62
57
  <div className="space-y-3">
63
58
  <div className="flex flex-wrap items-center gap-2">
64
59
  <span className="text-sm font-semibold">Current plan:</span>
65
- <span className="text-sm">{plan.name}</span>
60
+ <span className="text-sm">{currentPlan.name}</span>
66
61
  <Badge variant={status.variant}>{status.label}</Badge>
67
62
  </div>
68
- {sub.status === 'trialing' && trialEndsAt && (
69
- <p className="text-muted-foreground text-[13px]">
70
- Trial ends on {trialEndsAt}. Your card will be charged then unless
71
- you cancel.
63
+ {subscription.status === 'trialing' && subscription.trialEndsAt ? (
64
+ <p className="text-muted-foreground text-sm">
65
+ Your trial ends on {trialEndsAt}. Stripe charges your card then
66
+ unless you cancel.
72
67
  </p>
73
- )}
74
- {sub.status === 'active' && renewsAt && (
75
- <p className="text-muted-foreground text-[13px]">
76
- {sub.cancelAtPeriodEnd
77
- ? `Access ends on ${renewsAt}.`
78
- : `Renews on ${renewsAt}.`}
68
+ ) : null}
69
+ {subscription.status === 'active' && subscription.currentPeriodEnd ? (
70
+ <p className="text-muted-foreground text-sm">
71
+ {subscription.cancelAtPeriodEnd
72
+ ? `Your plan ends on ${renewsAt}.`
73
+ : `Your plan renews on ${renewsAt}.`}
79
74
  </p>
80
- )}
81
- {sub.status === 'past_due' && (
82
- <p className="text-destructive text-[13px]">
83
- Your last payment failed. Update your payment method to keep access.
75
+ ) : null}
76
+ {subscription.status === 'past_due' ? (
77
+ <p className="text-destructive text-sm">
78
+ Your last payment failed. Update your payment method from Manage
79
+ Billing to keep your plan.
84
80
  </p>
85
- )}
81
+ ) : null}
86
82
  </div>
87
83
 
88
84
  <Separator />
@@ -90,27 +86,30 @@ export default async function Page({
90
86
  <div className="space-y-1">
91
87
  <h2 className="text-lg font-semibold">Plans</h2>
92
88
  <p className="text-muted-foreground text-sm">
93
- Choose the plan that fits your organization.
89
+ Upgrade, switch, or cancel at any time.
94
90
  </p>
95
91
  </div>
96
92
  <PlansSection
97
93
  orgId={orgId}
98
- plans={PLANS}
99
- currentPlanId={plan.id}
100
- currentStatus={sub.status}
101
- hasBillingEmail={Boolean(sub.billingEmail)}
102
- canStartTrial={canStartTrial}
94
+ plans={plans}
95
+ currentPlanId={currentPlan.id}
96
+ hasActiveSub={subscription.hasLiveSubscription}
97
+ hasBillingEmail={Boolean(subscription.billingEmail)}
98
+ canStartTrial={subscription.canStartTrial}
103
99
  />
104
100
 
105
101
  <Separator />
106
102
 
107
103
  <div className="space-y-1">
108
- <h2 className="text-lg font-semibold">Billing email</h2>
104
+ <h2 className="text-lg font-semibold">Billing Email</h2>
109
105
  <p className="text-muted-foreground text-sm">
110
- Where invoices and receipts are sent.
106
+ Stripe sends invoices and receipts to this address.
111
107
  </p>
112
108
  </div>
113
- <BillingEmailForm orgId={orgId} defaultBillingEmail={sub.billingEmail} />
109
+ <BillingEmailForm
110
+ orgId={orgId}
111
+ defaultBillingEmail={subscription.billingEmail}
112
+ />
114
113
  </div>
115
114
  )
116
115
  }