create-ej-app 26.0.0 → 27.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 +46 -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 +49 -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
@@ -0,0 +1,56 @@
1
+ import type { ReactNode } from 'react'
2
+ import { siteConfig } from '~/lib/siteConfig'
3
+
4
+ /** Page shell shared by the privacy policy and the terms of service. */
5
+ export function LegalPage({
6
+ title,
7
+ children
8
+ }: {
9
+ title: string
10
+ children: ReactNode
11
+ }) {
12
+ return (
13
+ <article className="container mx-auto max-w-4xl px-4 py-12 md:px-0">
14
+ <h1 className="mb-8 text-4xl font-bold">{title}</h1>
15
+ <div className="text-muted-foreground space-y-6 leading-relaxed">
16
+ <p className="text-foreground font-medium">
17
+ Last updated: {siteConfig.legal.lastUpdated}
18
+ </p>
19
+ {children}
20
+ </div>
21
+ </article>
22
+ )
23
+ }
24
+
25
+ /** One numbered section of a legal page. Number the title yourself. */
26
+ export function LegalSection({
27
+ title,
28
+ children
29
+ }: {
30
+ title: string
31
+ children: ReactNode
32
+ }) {
33
+ return (
34
+ <section className="space-y-4">
35
+ <h2 className="text-foreground mt-8 text-2xl font-semibold">{title}</h2>
36
+ {children}
37
+ </section>
38
+ )
39
+ }
40
+
41
+ /** Bulleted list styled for legal page body copy. */
42
+ export function LegalList({ children }: { children: ReactNode }) {
43
+ return <ul className="list-disc space-y-2 pl-6">{children}</ul>
44
+ }
45
+
46
+ const linkClassName =
47
+ 'text-primary focus-visible:ring-ring/50 rounded-sm underline underline-offset-4 focus-visible:ring-3 focus-visible:outline-none'
48
+
49
+ /** Inline mailto link to the support address. */
50
+ export function ContactEmailLink() {
51
+ return (
52
+ <a href={`mailto:${siteConfig.contact.email}`} className={linkClassName}>
53
+ {siteConfig.contact.email}
54
+ </a>
55
+ )
56
+ }
@@ -1,3 +1,21 @@
1
- export default async function HomePage() {
2
- return <div>Hello, World!</div>
1
+ import Link from 'next/link'
2
+ import { Button } from '~/components/ui/button'
3
+ import { siteConfig } from '~/lib/siteConfig'
4
+
5
+ export default function HomePage() {
6
+ return (
7
+ <section className="container flex flex-col items-center gap-6 py-24 text-center">
8
+ <h1 className="max-w-2xl text-4xl font-bold tracking-tight sm:text-5xl">
9
+ {siteConfig.name}
10
+ </h1>
11
+ <p className="text-muted-foreground max-w-xl text-lg">
12
+ {siteConfig.description}
13
+ </p>
14
+ <div className="flex flex-wrap justify-center gap-3">
15
+ <Button asChild size="lg">
16
+ <Link href="/login">Get Started</Link>
17
+ </Button>
18
+ </div>
19
+ </section>
20
+ )
3
21
  }
@@ -1,3 +1,44 @@
1
- export default async function PrivacyPage() {
2
- return <div>Hello, World!</div>
1
+ import type { Metadata } from 'next'
2
+ import { siteConfig } from '~/lib/siteConfig'
3
+ import { ContactEmailLink, LegalList, LegalPage, LegalSection } from '../legal'
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'Privacy Policy',
7
+ description: `How ${siteConfig.name} collects, uses, and protects your data.`
8
+ }
9
+
10
+ // Starting point only. Have this reviewed before you launch, and update it
11
+ // with `siteConfig.legal.lastUpdated` whenever the product's data use changes.
12
+ export default function PrivacyPage() {
13
+ return (
14
+ <LegalPage title="Privacy Policy">
15
+ <p>
16
+ This policy explains what {siteConfig.name} collects, why, and what you
17
+ can do about it.
18
+ </p>
19
+
20
+ <LegalSection title="1. Information You Give Us">
21
+ <LegalList>
22
+ <li>
23
+ <strong>Account details</strong>: your name and email address. When
24
+ you sign in with Google or GitHub, that provider also shares your
25
+ profile picture.
26
+ </li>
27
+ </LegalList>
28
+ </LegalSection>
29
+
30
+ <LegalSection title="2. How We Use It">
31
+ <p>
32
+ We use this information to run your account and send the emails you
33
+ ask for.
34
+ </p>
35
+ </LegalSection>
36
+
37
+ <LegalSection title="3. Contact">
38
+ <p>
39
+ Email <ContactEmailLink /> with questions about your data.
40
+ </p>
41
+ </LegalSection>
42
+ </LegalPage>
43
+ )
3
44
  }
@@ -1,3 +1,34 @@
1
- export default async function TermsPage() {
2
- return <div>Hello, World!</div>
1
+ import type { Metadata } from 'next'
2
+ import { siteConfig } from '~/lib/siteConfig'
3
+ import { ContactEmailLink, LegalPage, LegalSection } from '../legal'
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'Terms of Service',
7
+ description: `The terms that apply when you use ${siteConfig.name}.`
8
+ }
9
+
10
+ // Starting point only. Have this reviewed before you launch, and update it
11
+ // with `siteConfig.legal.lastUpdated` whenever the product's behavior changes.
12
+ export default function TermsPage() {
13
+ return (
14
+ <LegalPage title="Terms of Service">
15
+ <p>
16
+ These terms apply when you use {siteConfig.name}. By creating an
17
+ account, you agree to them.
18
+ </p>
19
+
20
+ <LegalSection title="1. Your Account">
21
+ <p>
22
+ Keep access to your email inbox secure: anyone who can read it can
23
+ sign in as you.
24
+ </p>
25
+ </LegalSection>
26
+
27
+ <LegalSection title="2. Contact">
28
+ <p>
29
+ Email <ContactEmailLink /> with questions about these terms.
30
+ </p>
31
+ </LegalSection>
32
+ </LegalPage>
33
+ )
3
34
  }
@@ -0,0 +1,37 @@
1
+ 'use client'
2
+
3
+ import { usePathname } from 'next/navigation'
4
+ import { useEffect } from 'react'
5
+ import { RouteError } from '~/components/route-error'
6
+
7
+ /**
8
+ * Root segment error boundary.
9
+ *
10
+ * Catches runtime errors in pages and nested layouts under the root layout.
11
+ * Does not catch errors thrown in the root layout itself; see `global-error.tsx`.
12
+ */
13
+ export default function Error({
14
+ error,
15
+ unstable_retry
16
+ }: {
17
+ error: Error & { digest?: string }
18
+ unstable_retry: () => void
19
+ }) {
20
+ const pathname = usePathname()
21
+ const homeHref =
22
+ pathname?.startsWith('/admin') && pathname !== '/admin' ? '/admin' : '/'
23
+ const homeLabel = homeHref === '/admin' ? 'Back to Admin' : 'Go Home'
24
+
25
+ useEffect(() => {
26
+ console.error('Route error boundary:', error)
27
+ }, [error])
28
+
29
+ return (
30
+ <RouteError
31
+ error={error}
32
+ onRetry={unstable_retry}
33
+ homeHref={homeHref}
34
+ homeLabel={homeLabel}
35
+ />
36
+ )
37
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @fileoverview Global font definitions shared across all route groups.
3
+ *
4
+ * Fonts are defined here in a single module so that `next/font/local` generates
5
+ * a stable CSS module hash regardless of which layout imports them. Defining
6
+ * fonts inside individual route-group layouts (e.g. `(marketing)` or `(admin)`)
7
+ * causes the server and client to produce different class name hashes, leading
8
+ * to React hydration mismatches.
9
+ *
10
+ * Usage:
11
+ * import { geistMono, geistSans } from '~/app/fonts'
12
+ * <body className={`${geistSans.variable} ${geistMono.variable}`}>
13
+ *
14
+ * @module app/fonts/index
15
+ */
16
+
17
+ import localFont from 'next/font/local'
18
+
19
+ /**
20
+ * Geist Sans - primary variable font for body and UI text.
21
+ *
22
+ * Exposed as the CSS custom property `--font-geist-sans` so it can be
23
+ * referenced in Tailwind via `font-sans`.
24
+ *
25
+ * @see https://vercel.com/font/sans
26
+ */
27
+ export const geistSans = localFont({
28
+ src: '../fonts/geist-sans/Geist-Variable.woff2',
29
+ variable: '--font-geist-sans',
30
+ weight: '100 900'
31
+ })
32
+
33
+ /**
34
+ * Geist Mono - monospace variable font for code blocks, badges, and
35
+ * any UI element that benefits from fixed-width rendering.
36
+ *
37
+ * Exposed as the CSS custom property `--font-geist-mono`.
38
+ *
39
+ * `adjustFontFallback` is disabled because the automatic metric adjustment
40
+ * produces undesirable layout shifts with the custom fallback stack below.
41
+ *
42
+ * @see https://vercel.com/font/mono
43
+ */
44
+ export const geistMono = localFont({
45
+ src: '../fonts/geist-mono/GeistMono-Variable.woff2',
46
+ variable: '--font-geist-mono',
47
+ adjustFontFallback: false,
48
+ fallback: [
49
+ 'ui-monospace',
50
+ 'SFMono-Regular',
51
+ 'Roboto Mono',
52
+ 'Menlo',
53
+ 'Monaco',
54
+ 'Liberation Mono',
55
+ 'DejaVu Sans Mono',
56
+ 'Courier New',
57
+ 'monospace'
58
+ ],
59
+ weight: '100 900'
60
+ })
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { RouteError } from '~/components/route-error'
5
+ import { siteConfig } from '~/lib/siteConfig'
6
+ import { geistMono, geistSans } from './fonts'
7
+ import './globals.css'
8
+
9
+ /**
10
+ * Root layout error boundary.
11
+ *
12
+ * Replaces the entire root layout when an error is thrown there.
13
+ * Must define its own document shell (`html` / `body`) and styles.
14
+ */
15
+ export default function GlobalError({
16
+ error,
17
+ unstable_retry
18
+ }: {
19
+ error: Error & { digest?: string }
20
+ unstable_retry: () => void
21
+ }) {
22
+ useEffect(() => {
23
+ console.error('Global error boundary:', error)
24
+ }, [error])
25
+
26
+ return (
27
+ <html
28
+ lang="en"
29
+ suppressHydrationWarning
30
+ className={`${geistSans.variable} ${geistMono.variable}`}
31
+ >
32
+ <body>
33
+ <title>This Page Failed to Load | {siteConfig.name}</title>
34
+ <RouteError error={error} onRetry={unstable_retry} />
35
+ </body>
36
+ </html>
37
+ )
38
+ }
@@ -28,6 +28,14 @@
28
28
  --color-chart-3: var(--chart-3);
29
29
  --color-chart-4: var(--chart-4);
30
30
  --color-chart-5: var(--chart-5);
31
+
32
+ --color-success: var(--success);
33
+ --color-success-foreground: var(--success-foreground);
34
+ --color-warning: var(--warning);
35
+ --color-warning-foreground: var(--warning-foreground);
36
+ --color-info: var(--info);
37
+ --color-info-foreground: var(--info-foreground);
38
+
31
39
  --color-sidebar: var(--sidebar);
32
40
  --color-sidebar-foreground: var(--sidebar-foreground);
33
41
  --color-sidebar-primary: var(--sidebar-primary);
@@ -84,6 +92,13 @@
84
92
  --sidebar-accent-foreground: oklch(0.205 0 0);
85
93
  --sidebar-border: oklch(0.922 0 0);
86
94
  --sidebar-ring: oklch(0.708 0 0);
95
+
96
+ --success: oklch(0.552 0.152 149.579);
97
+ --success-foreground: oklch(1 0 0);
98
+ --warning: oklch(0.574 0.188 70.08);
99
+ --warning-foreground: oklch(1 0 0);
100
+ --info: oklch(0.569 0.165 254.624);
101
+ --info-foreground: oklch(1 0 0);
87
102
  }
88
103
 
89
104
  .dark {
@@ -119,6 +134,13 @@
119
134
  --sidebar-accent-foreground: oklch(0.985 0 0);
120
135
  --sidebar-border: oklch(1 0 0 / 10%);
121
136
  --sidebar-ring: oklch(0.556 0 0);
137
+
138
+ --success: oklch(0.723 0.192 149.579);
139
+ --success-foreground: oklch(0.145 0 0);
140
+ --warning: oklch(0.769 0.188 70.08);
141
+ --warning-foreground: oklch(0.205 0 0);
142
+ --info: oklch(0.707 0.165 254.624);
143
+ --info-foreground: oklch(0.205 0 0);
122
144
  }
123
145
 
124
146
  @layer base {
@@ -128,6 +150,7 @@
128
150
 
129
151
  html {
130
152
  @apply font-sans antialiased;
153
+ scroll-behavior: smooth;
131
154
  }
132
155
 
133
156
  body {
@@ -138,6 +161,12 @@
138
161
  [role='button']:not([disabled]) {
139
162
  cursor: pointer;
140
163
  }
164
+
165
+ @media (prefers-reduced-motion: reduce) {
166
+ html {
167
+ scroll-behavior: auto;
168
+ }
169
+ }
141
170
  }
142
171
 
143
172
  @theme inline {
@@ -1,27 +1,49 @@
1
- import type { Metadata } from 'next'
2
- import { Geist, Geist_Mono } from 'next/font/google'
1
+ /**
2
+ * @fileoverview Root layout: document shell, fonts, metadata, and the
3
+ * client providers every page shares (TanStack Query, theme, tooltips,
4
+ * toasts, and the navigation progress bar).
5
+ *
6
+ * @module app/layout
7
+ */
8
+
9
+ import type { Metadata, Viewport } from 'next'
3
10
  import { Loader } from '~/components/loader'
4
11
  import { ReactQueryProvider } from '~/components/react-query-provider'
5
12
  import { ScreenSize } from '~/components/screen-size'
6
13
  import { ThemeProvider } from '~/components/theme-provider'
7
14
  import { Toaster } from '~/components/ui/sonner'
15
+ import { TooltipProvider } from '~/components/ui/tooltip'
8
16
  import { env } from '~/env'
9
17
  import { siteConfig } from '~/lib/siteConfig'
18
+ import { geistMono, geistSans } from './fonts'
10
19
  import './globals.css'
11
20
 
12
- const geistSans = Geist({
13
- variable: '--font-geist-sans',
14
- subsets: ['latin']
15
- })
16
-
17
- const geistMono = Geist_Mono({
18
- variable: '--font-geist-mono',
19
- subsets: ['latin']
20
- })
21
-
22
21
  export const metadata: Metadata = {
23
- title: siteConfig.name,
24
- description: siteConfig.description
22
+ metadataBase: new URL(env.BETTER_AUTH_URL),
23
+ title: {
24
+ default: siteConfig.name,
25
+ template: `%s | ${siteConfig.name}`
26
+ },
27
+ description: siteConfig.description,
28
+ applicationName: siteConfig.name,
29
+ openGraph: {
30
+ type: 'website',
31
+ locale: 'en_US',
32
+ siteName: siteConfig.name,
33
+ description: siteConfig.description
34
+ },
35
+ twitter: {
36
+ card: 'summary_large_image',
37
+ description: siteConfig.description
38
+ }
39
+ }
40
+
41
+ /** Browser chrome colors, matching `--background` in globals.css. */
42
+ export const viewport: Viewport = {
43
+ themeColor: [
44
+ { media: '(prefers-color-scheme: light)', color: '#ffffff' },
45
+ { media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }
46
+ ]
25
47
  }
26
48
 
27
49
  export default function RootLayout({
@@ -38,13 +60,18 @@ export default function RootLayout({
38
60
  <body>
39
61
  <ReactQueryProvider>
40
62
  <ThemeProvider>
41
- <Loader />
42
- {children}
43
- {env.APP_ENV === 'development' ? <ScreenSize /> : null}
44
- <Toaster />
63
+ <TooltipProvider>
64
+ <Loader />
65
+ {children}
66
+ {env.APP_ENV === 'development' ? <ScreenSize /> : null}
67
+ <Toaster />
68
+ </TooltipProvider>
45
69
  </ThemeProvider>
46
70
  </ReactQueryProvider>
47
71
  </body>
48
72
  </html>
49
73
  )
50
74
  }
75
+
76
+ /** Renders every request dynamically, so env-driven metadata stays current. */
77
+ export const dynamic = 'force-dynamic'
@@ -1,12 +1,34 @@
1
+ /**
2
+ * @fileoverview 404 Not Found Page
3
+ *
4
+ * Global catch-all page displayed when a route is not found.
5
+ * Redirects users back to the appropriate home page based on the current route context.
6
+ *
7
+ * **Behavior:**
8
+ * - Admin routes (/admin/*) redirect to /admin
9
+ * - All other routes redirect to /
10
+ *
11
+ * @module app/not-found
12
+ */
13
+
1
14
  'use client'
2
15
 
3
16
  import Link from 'next/link'
4
17
  import { usePathname } from 'next/navigation'
5
18
  import { Button } from '~/components/ui/button'
6
19
 
20
+ /**
21
+ * Not Found Page Component
22
+ *
23
+ * Displays a 404 error message with context-aware home button.
24
+ * Uses the current pathname to determine the appropriate home route.
25
+ *
26
+ * @returns {JSX.Element} 404 error page with navigation button
27
+ */
7
28
  export default function NotFound() {
8
29
  const pathname = usePathname()
9
- const homeRoute = pathname?.startsWith('/admin') ? '/admin' : '/'
30
+ const homeRoute =
31
+ pathname?.startsWith('/admin') && pathname !== '/admin' ? '/admin' : '/'
10
32
 
11
33
  return (
12
34
  <main className="bg-background text-foreground flex min-h-screen flex-col">
@@ -15,10 +37,10 @@ export default function NotFound() {
15
37
  <h1 className="text-foreground mb-2 text-6xl font-bold">404</h1>
16
38
  <p className="text-muted-foreground mb-8 text-xl">Page not found</p>
17
39
  <p className="text-muted-foreground mb-8 max-w-md text-base">
18
- The page you're looking for doesn't exist.
40
+ The page you&apos;re looking for doesn&apos;t exist.
19
41
  </p>
20
42
  <Button asChild>
21
- <Link href={homeRoute}>Return to Home</Link>
43
+ <Link href={homeRoute}>Return Home</Link>
22
44
  </Button>
23
45
  </div>
24
46
  </section>
@@ -0,0 +1,29 @@
1
+ import type { MetadataRoute } from 'next'
2
+ import { env } from '~/env'
3
+
4
+ /** Renders every request dynamically, so the environment check is live. */
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ /**
8
+ * robots.txt. Staging blocks every crawler; production keeps signed-in
9
+ * surfaces and the API out of search results.
10
+ */
11
+ export default function robots(): MetadataRoute.Robots {
12
+ if (env.APP_ENV === 'staging') {
13
+ return { rules: [{ userAgent: '*', disallow: '/' }] }
14
+ }
15
+
16
+ const baseUrl = env.BETTER_AUTH_URL
17
+
18
+ return {
19
+ rules: [
20
+ {
21
+ userAgent: '*',
22
+ allow: '/',
23
+ disallow: ['/api/', '/admin', '/app', '/profile']
24
+ }
25
+ ],
26
+ sitemap: `${baseUrl}/sitemap.xml`,
27
+ host: baseUrl
28
+ }
29
+ }
@@ -0,0 +1,21 @@
1
+ import type { MetadataRoute } from 'next'
2
+ import { env } from '~/env'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ /** Public pages only. Staging returns an empty sitemap. */
7
+ export default function sitemap(): MetadataRoute.Sitemap {
8
+ if (env.APP_ENV === 'staging') return []
9
+
10
+ const baseUrl = env.BETTER_AUTH_URL
11
+ const lastModified = new Date()
12
+
13
+ const routes = ['/', '/privacy', '/terms', '/login']
14
+
15
+ return routes.map(path => ({
16
+ url: `${baseUrl}${path === '/' ? '' : path}`,
17
+ lastModified,
18
+ changeFrequency: path === '/' ? 'weekly' : 'monthly',
19
+ priority: path === '/' ? 1 : 0.7
20
+ }))
21
+ }
@@ -0,0 +1,79 @@
1
+ 'use client'
2
+
3
+ import { Loader2 } from 'lucide-react'
4
+ import type { ReactNode } from 'react'
5
+ import {
6
+ AlertDialog,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle
13
+ } from '~/components/ui/alert-dialog'
14
+ import { Button } from '~/components/ui/button'
15
+
16
+ /**
17
+ * Controlled confirmation for destructive or hard-to-undo actions. Use it
18
+ * instead of `window.confirm`.
19
+ *
20
+ * The dialog stays open while `pending` is true; close it from the
21
+ * caller's success handler by clearing whatever drives `open`.
22
+ *
23
+ * @example
24
+ * <ConfirmDialog
25
+ * open={target !== null}
26
+ * onOpenChange={open => !open && setTarget(null)}
27
+ * title="Delete User"
28
+ * description="This permanently deletes the account."
29
+ * confirmLabel="Delete User"
30
+ * pending={isDeleting}
31
+ * onConfirm={() => deleteUser(target)}
32
+ * />
33
+ */
34
+ export function ConfirmDialog({
35
+ open,
36
+ onOpenChange,
37
+ title,
38
+ description,
39
+ confirmLabel,
40
+ pending = false,
41
+ destructive = true,
42
+ onConfirm
43
+ }: {
44
+ open: boolean
45
+ onOpenChange: (open: boolean) => void
46
+ title: string
47
+ description: ReactNode
48
+ confirmLabel: string
49
+ pending?: boolean
50
+ destructive?: boolean
51
+ onConfirm: () => void
52
+ }) {
53
+ return (
54
+ <AlertDialog
55
+ open={open}
56
+ onOpenChange={next => {
57
+ if (!pending) onOpenChange(next)
58
+ }}
59
+ >
60
+ <AlertDialogContent>
61
+ <AlertDialogHeader>
62
+ <AlertDialogTitle>{title}</AlertDialogTitle>
63
+ <AlertDialogDescription>{description}</AlertDialogDescription>
64
+ </AlertDialogHeader>
65
+ <AlertDialogFooter>
66
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
67
+ <Button
68
+ variant={destructive ? 'destructive' : 'default'}
69
+ disabled={pending}
70
+ onClick={onConfirm}
71
+ >
72
+ {pending ? <Loader2 className="animate-spin" /> : null}
73
+ {confirmLabel}
74
+ </Button>
75
+ </AlertDialogFooter>
76
+ </AlertDialogContent>
77
+ </AlertDialog>
78
+ )
79
+ }