create-ej-app 25.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 (385) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +83 -28
  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 -0
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -40
  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 +8 -0
  140. package/template/saas-starter/.env.example +25 -0
  141. package/template/saas-starter/.vscode/extensions.json +11 -0
  142. package/template/saas-starter/.vscode/settings.json +3 -0
  143. package/template/saas-starter/AGENTS.md +302 -0
  144. package/template/saas-starter/CLAUDE.md +1 -0
  145. package/template/saas-starter/Dockerfile +15 -0
  146. package/template/saas-starter/README.md +125 -0
  147. package/template/saas-starter/bunfig.toml +3 -0
  148. package/template/saas-starter/components.json +25 -0
  149. package/template/saas-starter/drizzle.config.ts +10 -0
  150. package/template/saas-starter/eslint.config.mjs +24 -0
  151. package/template/saas-starter/gitignore +14 -0
  152. package/template/saas-starter/next.config.ts +32 -0
  153. package/template/saas-starter/package.json +97 -0
  154. package/template/saas-starter/postcss.config.mjs +7 -0
  155. package/template/saas-starter/prettier.config.mjs +11 -0
  156. package/template/saas-starter/public/logo-dark.svg +4 -0
  157. package/template/saas-starter/public/logo.svg +4 -0
  158. package/template/saas-starter/public/placeholder.svg +1 -0
  159. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  160. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  161. package/template/saas-starter/src/app/(admin)/actions/users.ts +183 -0
  162. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  163. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  164. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  165. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  166. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  167. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +53 -0
  168. package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
  169. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  170. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  171. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  172. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  173. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  174. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  175. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  176. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  177. package/template/saas-starter/src/app/(app)/actions/billing.ts +216 -0
  178. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  179. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  180. package/template/saas-starter/src/app/(app)/actions/members.ts +189 -0
  181. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  182. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +17 -0
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
  188. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
  189. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
  190. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  191. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +168 -0
  192. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
  193. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
  194. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
  195. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  196. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  197. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +31 -0
  198. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
  199. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
  200. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  201. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +37 -0
  202. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
  203. package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
  204. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
  205. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
  206. package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
  207. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  208. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  209. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  210. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  211. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  212. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +131 -0
  213. package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
  214. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
  215. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
  216. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
  217. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  218. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  219. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  220. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  221. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  222. package/template/saas-starter/src/app/(marketing)/layout.tsx +48 -0
  223. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  224. package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
  225. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
  226. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
  227. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  228. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  229. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  230. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  231. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  232. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  233. package/template/saas-starter/src/app/error.tsx +37 -0
  234. package/template/saas-starter/src/app/favicon.ico +0 -0
  235. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  236. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  237. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  238. package/template/saas-starter/src/app/global-error.tsx +38 -0
  239. package/template/saas-starter/src/app/globals.css +269 -0
  240. package/template/saas-starter/src/app/layout.tsx +77 -0
  241. package/template/saas-starter/src/app/not-found.tsx +49 -0
  242. package/template/saas-starter/src/app/robots.ts +36 -0
  243. package/template/saas-starter/src/app/sitemap.ts +21 -0
  244. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  245. package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
  246. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  247. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  248. package/template/saas-starter/src/components/data-table/index.tsx +961 -0
  249. package/template/saas-starter/src/components/data-table/types.ts +76 -0
  250. package/template/saas-starter/src/components/error.tsx +71 -0
  251. package/template/saas-starter/src/components/file-upload.tsx +254 -0
  252. package/template/saas-starter/src/components/loader.tsx +41 -0
  253. package/template/saas-starter/src/components/logo.tsx +49 -0
  254. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  255. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  256. package/template/saas-starter/src/components/page-heading.tsx +38 -0
  257. package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
  258. package/template/saas-starter/src/components/route-error.tsx +55 -0
  259. package/template/saas-starter/src/components/screen-size.tsx +76 -0
  260. package/template/saas-starter/src/components/site-footer.tsx +39 -0
  261. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  262. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  263. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  264. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  265. package/template/saas-starter/src/components/theme-provider.tsx +115 -0
  266. package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
  267. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  268. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  269. package/template/saas-starter/src/components/ui/badge.tsx +54 -0
  270. package/template/saas-starter/src/components/ui/button.tsx +69 -0
  271. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  272. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  273. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  274. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  275. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  276. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  277. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  278. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  279. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  280. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  281. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  282. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  283. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  284. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  285. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  286. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  287. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  288. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  289. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  290. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  291. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  292. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  293. package/template/saas-starter/src/components/user-menu.tsx +213 -0
  294. package/template/saas-starter/src/db/common.ts +63 -0
  295. package/template/saas-starter/src/db/enums.ts +50 -0
  296. package/template/saas-starter/src/db/index.ts +60 -0
  297. package/template/saas-starter/src/db/relations.ts +112 -0
  298. package/template/saas-starter/src/db/schema.ts +342 -0
  299. package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
  300. package/template/saas-starter/src/emails/invitation.tsx +69 -0
  301. package/template/saas-starter/src/emails/magic-link.tsx +51 -0
  302. package/template/saas-starter/src/emails/test.tsx +34 -0
  303. package/template/saas-starter/src/emails/welcome.tsx +37 -0
  304. package/template/saas-starter/src/env.ts +66 -0
  305. package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
  306. package/template/saas-starter/src/instrumentation.ts +44 -0
  307. package/template/saas-starter/src/lib/actions.ts +55 -0
  308. package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
  309. package/template/saas-starter/src/lib/app-navigation.ts +63 -0
  310. package/template/saas-starter/src/lib/auth-client.ts +34 -0
  311. package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
  312. package/template/saas-starter/src/lib/auth.ts +312 -0
  313. package/template/saas-starter/src/lib/billing.ts +152 -0
  314. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  315. package/template/saas-starter/src/lib/cn.ts +4 -0
  316. package/template/saas-starter/src/lib/constants.ts +91 -0
  317. package/template/saas-starter/src/lib/delay.ts +12 -0
  318. package/template/saas-starter/src/lib/email-service.ts +118 -0
  319. package/template/saas-starter/src/lib/errors.ts +74 -0
  320. package/template/saas-starter/src/lib/format-currency.ts +25 -0
  321. package/template/saas-starter/src/lib/format-date.ts +75 -0
  322. package/template/saas-starter/src/lib/image-url.ts +54 -0
  323. package/template/saas-starter/src/lib/nodemailer.ts +67 -0
  324. package/template/saas-starter/src/lib/organization-access.ts +73 -0
  325. package/template/saas-starter/src/lib/otel.ts +117 -0
  326. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  327. package/template/saas-starter/src/lib/plans.ts +90 -0
  328. package/template/saas-starter/src/lib/rbac.ts +44 -0
  329. package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
  330. package/template/saas-starter/src/lib/safe-action.ts +187 -0
  331. package/template/saas-starter/src/lib/search.ts +16 -0
  332. package/template/saas-starter/src/lib/seo.ts +7 -0
  333. package/template/saas-starter/src/lib/siteConfig.ts +20 -0
  334. package/template/saas-starter/src/lib/storage.ts +311 -0
  335. package/template/saas-starter/src/lib/stripe.ts +9 -0
  336. package/template/saas-starter/src/lib/toast-message.ts +101 -0
  337. package/template/saas-starter/src/lib/validations.ts +95 -0
  338. package/template/saas-starter/src/proxy.ts +48 -0
  339. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  340. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  341. package/template/saas-starter/src/scripts/seed.ts +85 -0
  342. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  343. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
  344. package/template/saas-starter/src/scripts/test-mail.ts +43 -0
  345. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  346. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  347. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  348. package/template/saas-starter/tests/helpers/action.ts +71 -0
  349. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  350. package/template/saas-starter/tests/helpers/index.ts +2 -0
  351. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  352. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  353. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  354. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  355. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  356. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  357. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  358. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  359. package/template/saas-starter/tests/setup/preload.ts +108 -0
  360. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  361. package/template/saas-starter/tsconfig.json +52 -0
  362. package/cli.js +0 -143
  363. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  364. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  365. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  366. package/template/nextjs/terraform/backend-service.tf +0 -13
  367. package/template/nextjs/terraform/cleanup.sh +0 -9
  368. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  369. package/template/nextjs/terraform/cloudrun.tf +0 -68
  370. package/template/nextjs/terraform/database.tf +0 -60
  371. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  372. package/template/nextjs/terraform/gitignore +0 -4
  373. package/template/nextjs/terraform/ip-address.tf +0 -11
  374. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  375. package/template/nextjs/terraform/network.tf +0 -21
  376. package/template/nextjs/terraform/pre.sh +0 -3
  377. package/template/nextjs/terraform/providers.tf +0 -18
  378. package/template/nextjs/terraform/secret.txt +0 -11
  379. package/template/nextjs/terraform/setup.sh +0 -25
  380. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  381. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  382. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  383. package/template/nextjs/terraform/terraform.tfvars +0 -4
  384. package/template/nextjs/terraform/url-map.tf +0 -23
  385. package/template/nextjs/terraform/variables.tf +0 -21
@@ -1,3 +1,5 @@
1
+ import 'server-only'
2
+
1
3
  import { render, toPlainText } from '@react-email/render'
2
4
  import MagicLinkEmail, { MagicLinkEmailProps } from '~/emails/magic-link'
3
5
  import WelcomeEmail, { WelcomeEmailProps } from '~/emails/welcome'
@@ -22,6 +24,9 @@ const componentMap: Record<
22
24
  magicLink: props => MagicLinkEmail(props as MagicLinkEmailProps)
23
25
  }
24
26
 
27
+ /**
28
+ * Generic email template renderer with type-safe overloads
29
+ */
25
30
  async function renderEmailTemplate<T extends EmailTemplateType>(
26
31
  templateType: T,
27
32
  props: EmailTemplateMap[T]
@@ -40,6 +45,14 @@ async function renderEmailTemplate(
40
45
  return { html, text }
41
46
  }
42
47
 
48
+ /**
49
+ * Sends welcome email to newly registered users
50
+ *
51
+ * @param to - Recipient email address
52
+ * @param props - Welcome email template props
53
+ * @returns Promise resolving to SentMessageInfo from nodemailer
54
+ * @throws Error if sending the email fails
55
+ */
43
56
  export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
44
57
  'use workflow'
45
58
 
@@ -53,6 +66,14 @@ export async function sendWelcomeEmail(to: string, props: WelcomeEmailProps) {
53
66
  })
54
67
  }
55
68
 
69
+ /**
70
+ * Sends magic link email for passwordless authentication
71
+ *
72
+ * @param to - Recipient email address
73
+ * @param props - Magic link email template props
74
+ * @returns Promise resolving to SentMessageInfo from nodemailer
75
+ * @throws Error if sending the email fails
76
+ */
56
77
  export async function sendMagicLinkEmail(
57
78
  to: string,
58
79
  props: MagicLinkEmailProps
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Expected, user-safe action failures.
3
+ *
4
+ * Throw these for domain and authorization failures. The safe-action
5
+ * `handleServerError` returns {@link AppError.message} to the client and
6
+ * records the code on the active span. Unexpected errors stay masked.
7
+ *
8
+ * Do not put stack traces, SQL, or internal IDs in `message`.
9
+ */
10
+ export const AppErrorCode = {
11
+ UNAUTHORIZED: 'UNAUTHORIZED',
12
+ FORBIDDEN: 'FORBIDDEN',
13
+ NOT_FOUND: 'NOT_FOUND',
14
+ CONFLICT: 'CONFLICT',
15
+ QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
16
+ FEATURE_NOT_INCLUDED: 'FEATURE_NOT_INCLUDED',
17
+ BAD_REQUEST: 'BAD_REQUEST',
18
+ INTERNAL: 'INTERNAL'
19
+ } as const
20
+
21
+ export type AppErrorCode = (typeof AppErrorCode)[keyof typeof AppErrorCode]
22
+
23
+ export class AppError extends Error {
24
+ readonly code: AppErrorCode
25
+ readonly meta?: Record<string, unknown>
26
+
27
+ constructor(
28
+ code: AppErrorCode,
29
+ message: string,
30
+ meta?: Record<string, unknown>
31
+ ) {
32
+ super(message)
33
+ this.name = 'AppError'
34
+ this.code = code
35
+ this.meta = meta
36
+ }
37
+ }
38
+
39
+ export function isAppError(error: unknown): error is AppError {
40
+ return error instanceof AppError
41
+ }
42
+
43
+ /**
44
+ * The Postgres SQLSTATE of a failed query. Drizzle wraps driver errors and
45
+ * keeps the original on `cause`, so read both.
46
+ */
47
+ function postgresCode(error: unknown, depth = 0): unknown {
48
+ // Drizzle adds one layer; the cap stops an error whose cause points back at
49
+ // itself from looping forever.
50
+ if (depth > 5 || typeof error !== 'object' || error === null) return undefined
51
+ if ('code' in error && typeof error.code === 'string') return error.code
52
+ return 'cause' in error ? postgresCode(error.cause, depth + 1) : undefined
53
+ }
54
+
55
+ export function isUniqueViolation(error: unknown): boolean {
56
+ return postgresCode(error) === '23505'
57
+ }
58
+
59
+ /**
60
+ * True when a delete or update is refused because another row still points at
61
+ * the row: a foreign key violation, or a `restrict` violation for keys declared
62
+ * `onDelete: 'restrict'`.
63
+ */
64
+ export function isForeignKeyViolation(error: unknown): boolean {
65
+ const code = postgresCode(error)
66
+ return code === '23503' || code === '23001'
67
+ }
68
+
69
+ export function rethrowUniqueViolation(error: unknown, message: string): never {
70
+ if (isUniqueViolation(error)) {
71
+ throw new AppError(AppErrorCode.CONFLICT, message)
72
+ }
73
+ throw error
74
+ }
@@ -1,27 +1,25 @@
1
- export const formatCurrency = (
2
- currencyCode: string,
3
- num: number | string,
4
- precision = 2
5
- ) => {
6
- const numCopy = Number(num)
1
+ const formatters = new Map<string, Intl.NumberFormat>()
7
2
 
8
- switch (currencyCode) {
9
- case 'INR':
10
- return new Intl.NumberFormat('en-IN', {
11
- style: 'currency',
12
- currency: 'INR',
13
- signDisplay: 'negative'
14
- }).format(numCopy)
15
- case 'USD':
16
- return new Intl.NumberFormat('en-US', {
17
- style: 'currency',
18
- currency: 'USD',
19
- signDisplay: 'negative'
20
- }).format(numCopy)
21
- default:
22
- console.warn(
23
- `Unsupported currency: ${currencyCode}. Defaulting to fixed format.`
24
- )
25
- return numCopy.toFixed(precision)
3
+ /**
4
+ * Formats an amount in its currency, such as `$20.00` or `€20.00`.
5
+ * `Intl` picks the symbol and the minor digits from the ISO currency code.
6
+ *
7
+ * @example
8
+ * formatCurrency('USD', 20) // '$20.00'
9
+ */
10
+ export function formatCurrency(
11
+ currencyCode: string,
12
+ value: number | string
13
+ ): string {
14
+ let formatter = formatters.get(currencyCode)
15
+ if (!formatter) {
16
+ formatter = new Intl.NumberFormat('en-US', {
17
+ style: 'currency',
18
+ currency: currencyCode,
19
+ currencyDisplay: 'narrowSymbol',
20
+ signDisplay: 'negative'
21
+ })
22
+ formatters.set(currencyCode, formatter)
26
23
  }
24
+ return formatter.format(Number(value))
27
25
  }
@@ -1,12 +1,75 @@
1
+ export type DateFormatOptions = {
2
+ /** Abbreviated month ("Jan") instead of the full name. */
3
+ short?: boolean
4
+ /** Append the time of day. */
5
+ time?: boolean
6
+ /** Drop the year. */
7
+ noYear?: boolean
8
+ /** Render in this IANA zone instead of the viewer's. */
9
+ timeZone?: string
10
+ }
11
+
12
+ function asValidDate(
13
+ date: Date | string | number | null | undefined
14
+ ): Date | null {
15
+ if (date == null) return null
16
+ const instant = new Date(date)
17
+ return Number.isFinite(instant.getTime()) ? instant : null
18
+ }
19
+
20
+ /**
21
+ * Date and time are formatted separately and joined with a fixed " at ",
22
+ * because the connector ICU inserts between them changed across versions
23
+ * (", " in ICU 72 and later). Bun, Node, and each browser ship different ICU
24
+ * data, so one `toLocaleString` call renders differently per viewer and
25
+ * breaks hydration.
26
+ */
27
+ function joinDateAndTime(
28
+ instant: Date,
29
+ dateOptions: Intl.DateTimeFormatOptions,
30
+ timeOptions: Intl.DateTimeFormatOptions,
31
+ timeZone: string | undefined
32
+ ): string {
33
+ const zone = timeZone ? { timeZone } : {}
34
+ const datePart = instant.toLocaleDateString('en-US', {
35
+ ...dateOptions,
36
+ ...zone
37
+ })
38
+ const timePart = instant.toLocaleTimeString('en-US', {
39
+ ...timeOptions,
40
+ ...zone
41
+ })
42
+ return `${datePart} at ${timePart}`
43
+ }
44
+
45
+ /**
46
+ * Formats a date as an `en-US` string, such as "March 3, 2026".
47
+ * Returns `-` for a missing or invalid date.
48
+ */
1
49
  export const formatDate = (
2
50
  date: Date | string | number | null | undefined,
3
- options?: { short?: boolean; time?: boolean; noYear?: boolean }
51
+ options?: DateFormatOptions
4
52
  ): string => {
5
- if (date == null) return '—'
6
- return new Date(date).toLocaleDateString('en-US', {
53
+ const instant = asValidDate(date)
54
+ if (!instant) return '-'
55
+
56
+ const dateOptions: Intl.DateTimeFormatOptions = {
7
57
  ...(options?.noYear ? {} : { year: 'numeric' }),
8
58
  month: options?.short ? 'short' : 'long',
9
- day: 'numeric',
10
- ...(options?.time ? { hour: '2-digit', minute: '2-digit' } : {})
11
- })
59
+ day: 'numeric'
60
+ }
61
+
62
+ if (!options?.time) {
63
+ return instant.toLocaleDateString('en-US', {
64
+ ...dateOptions,
65
+ ...(options?.timeZone ? { timeZone: options.timeZone } : {})
66
+ })
67
+ }
68
+
69
+ return joinDateAndTime(
70
+ instant,
71
+ dateOptions,
72
+ { hour: '2-digit', minute: '2-digit' },
73
+ options.timeZone
74
+ )
12
75
  }
@@ -1,16 +1,26 @@
1
+ import 'server-only'
2
+
1
3
  import nodemailer, { SentMessageInfo } from 'nodemailer'
2
4
  import { env } from '~/env'
5
+ import { AppError, AppErrorCode } from './errors'
3
6
 
7
+ /**
8
+ * Configure the email transporter using environment variables.
9
+ * Uses nodemailer for sending emails via SMTP.
10
+ */
4
11
  const transporter = nodemailer.createTransport({
5
12
  host: env.EMAIL_SERVER_HOST,
6
- port: parseInt(env.EMAIL_SERVER_PORT),
7
- secure: parseInt(env.EMAIL_SERVER_PORT) === 465, // Use secure for port 465, false for other ports
13
+ port: env.EMAIL_SERVER_PORT,
14
+ secure: env.EMAIL_SERVER_PORT === 465, // Use secure for port 465, false for other ports
8
15
  auth: {
9
16
  user: env.EMAIL_SERVER_USER,
10
17
  pass: env.EMAIL_SERVER_PASSWORD
11
18
  }
12
19
  })
13
20
 
21
+ /**
22
+ * Interface for the email payload
23
+ */
14
24
  interface SendEmailParams {
15
25
  to: string
16
26
  subject: string
@@ -18,6 +28,17 @@ interface SendEmailParams {
18
28
  html?: string
19
29
  }
20
30
 
31
+ /**
32
+ * Sends an email using the configured transporter.
33
+ *
34
+ * @param {SendEmailParams} params - The email parameters.
35
+ * @param {string} params.to - The recipient's email address.
36
+ * @param {string} params.subject - The subject of the email.
37
+ * @param {string} [params.text] - The plain text content of the email.
38
+ * @param {string} [params.html] - The HTML content of the email (optional).
39
+ * @returns {Promise<any>} Resolves when the email is sent successfully.
40
+ * @throws {Error} If sending the email fails.
41
+ */
21
42
  export const sendEmail = async ({
22
43
  to,
23
44
  subject,
@@ -38,6 +59,9 @@ export const sendEmail = async ({
38
59
  return info
39
60
  } catch (error) {
40
61
  console.error('Error sending email:', error)
41
- throw new Error('Failed to send email')
62
+ throw new AppError(
63
+ AppErrorCode.INTERNAL,
64
+ 'The email was not sent. Try again.'
65
+ )
42
66
  }
43
67
  }
@@ -1,17 +1,107 @@
1
- import { SpanStatusCode, trace } from '@opentelemetry/api'
1
+ import { SpanStatusCode, trace, type Attributes } from '@opentelemetry/api'
2
2
 
3
+ const MAX_STRING_ATTR_LENGTH = 256
4
+ const MAX_ARRAY_ATTR_ITEMS = 20
5
+
6
+ /**
7
+ * Helper function to retrieve the currently active span from the OpenTelemetry context.
8
+ * Useful for attaching additional information to the current trace.
9
+ */
3
10
  function getSpan() {
4
11
  const span = trace.getActiveSpan()
5
12
  return span
6
13
  }
7
14
 
8
- export function recordWideEvent(attributes: Record<string, any>) {
15
+ /**
16
+ * Records a "wide event" with arbitrary attributes to the current span.
17
+ * This allows adding high-cardinality data or domain-specific context
18
+ * to the trace without starting a new child span.
19
+ *
20
+ * @param attributes - Key-value pairs of attributes to add to the span
21
+ */
22
+ export function recordWideEvent(attributes: Attributes) {
9
23
  const span = getSpan()
10
24
  if (!span) return
11
25
 
12
26
  span.setAttributes(attributes)
13
27
  }
14
28
 
29
+ /**
30
+ * Flatten a value tree into OTEL-safe attributes under `prefix`.
31
+ * Nested objects use dotted keys; long strings are truncated; large
32
+ * arrays are capped. Use for action `parsedInput` on wide events.
33
+ */
34
+ export function flattenForWideEvent(
35
+ value: unknown,
36
+ prefix = 'input'
37
+ ): Attributes {
38
+ const out: Attributes = {}
39
+
40
+ const visit = (current: unknown, path: string) => {
41
+ if (current === null || current === undefined) return
42
+
43
+ if (typeof current === 'string') {
44
+ out[path] =
45
+ current.length > MAX_STRING_ATTR_LENGTH
46
+ ? `${current.slice(0, MAX_STRING_ATTR_LENGTH)}…`
47
+ : current
48
+ return
49
+ }
50
+
51
+ if (typeof current === 'number' || typeof current === 'boolean') {
52
+ out[path] = current
53
+ return
54
+ }
55
+
56
+ if (typeof current === 'bigint') {
57
+ out[path] = current.toString()
58
+ return
59
+ }
60
+
61
+ if (Array.isArray(current)) {
62
+ const limited = current.slice(0, MAX_ARRAY_ATTR_ITEMS)
63
+ const allPrimitive = limited.every(
64
+ item =>
65
+ item === null ||
66
+ item === undefined ||
67
+ typeof item === 'string' ||
68
+ typeof item === 'number' ||
69
+ typeof item === 'boolean'
70
+ )
71
+ if (allPrimitive) {
72
+ out[path] = limited.map(item =>
73
+ item === null || item === undefined ? '' : String(item)
74
+ )
75
+ if (current.length > MAX_ARRAY_ATTR_ITEMS) {
76
+ out[`${path}.length`] = current.length
77
+ }
78
+ return
79
+ }
80
+ out[`${path}.length`] = current.length
81
+ limited.forEach((item, index) => visit(item, `${path}.${index}`))
82
+ return
83
+ }
84
+
85
+ if (typeof current === 'object') {
86
+ for (const [key, child] of Object.entries(
87
+ current as Record<string, unknown>
88
+ )) {
89
+ visit(child, path ? `${path}.${key}` : key)
90
+ }
91
+ }
92
+ }
93
+
94
+ visit(value, prefix)
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * Records an exception to the current span and sets the span status to ERROR.
100
+ * captures the error message and stack trace if available.
101
+ *
102
+ * @param err - The error to record (can be an Error object or unknown)
103
+ * @returns The trace ID associated with the error, useful for correlation
104
+ */
15
105
  export function recordError(err: unknown) {
16
106
  const span = getSpan()
17
107
  if (!span) return
@@ -8,25 +8,52 @@ import {
8
8
  } from '@tanstack/react-query'
9
9
  import type { SafeActionResult } from 'next-safe-action'
10
10
 
11
- type AnyAction = (
11
+ /** Type helper for any server action function */
12
+ export type AnyAction = (
12
13
  ...args: any[]
13
14
  ) => Promise<SafeActionResult<any, any, any, any, any>>
15
+
16
+ /** Extracts the input type from a server action */
14
17
  type ActionInput<T extends AnyAction> = Parameters<T>[0]
18
+
19
+ /** Extracts the data type from a server action result */
15
20
  type ActionData<T extends AnyAction> = Awaited<ReturnType<T>>['data']
16
21
 
22
+ /**
23
+ * Client-side error for server action failures.
24
+ * Carries the user-facing `serverError` string (including unmasked AppError
25
+ * messages) and optional Zod validation errors.
26
+ */
17
27
  class SafeActionError extends Error {
18
28
  constructor(
19
29
  public message: string,
20
30
  public validationErrors?: Record<string, string[] | undefined>
21
31
  ) {
22
32
  super(message)
33
+ this.name = 'SafeActionError'
23
34
  }
24
35
  }
25
36
 
37
+ /**
38
+ * React Query hook for server actions that fetch data
39
+ * Wraps server actions with proper error handling and caching support
40
+ *
41
+ * @param key - Unique query key for caching
42
+ * @param action - Server action function to execute
43
+ * @param input - Input parameters for the server action (optional)
44
+ * @param options - React Query options (staleTime, refetchInterval, etc.)
45
+ *
46
+ * @returns React Query result with data, loading, and error states
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * const { data, isLoading } = useSafeActionQuery('cart-items', getCartItems)
51
+ * ```
52
+ */
26
53
  export function useSafeActionQuery<TAction extends AnyAction>(
27
54
  key: string,
28
55
  action: TAction,
29
- input: ActionInput<TAction>,
56
+ input?: ActionInput<TAction>,
30
57
  options?: Omit<UseQueryOptions<ActionData<TAction>>, 'queryKey' | 'queryFn'>
31
58
  ) {
32
59
  return useQuery({
@@ -39,7 +66,10 @@ export function useSafeActionQuery<TAction extends AnyAction>(
39
66
  }
40
67
 
41
68
  if (result?.validationErrors) {
42
- throw new SafeActionError('Validation failed', result.validationErrors)
69
+ throw new SafeActionError(
70
+ 'Check the highlighted fields and try again.',
71
+ result.validationErrors
72
+ )
43
73
  }
44
74
 
45
75
  return result?.data
@@ -48,13 +78,31 @@ export function useSafeActionQuery<TAction extends AnyAction>(
48
78
  })
49
79
  }
50
80
 
81
+ /**
82
+ * React Query mutation hook for server actions that modify data
83
+ * Wraps server actions with proper error handling and optimistic updates support
84
+ *
85
+ * @param action - Server action function to execute
86
+ * @param options - React Query mutation options (onSuccess, onError, etc.)
87
+ *
88
+ * @returns React Query mutation result with mutate function and states
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * const { mutate, isPending } = useSafeActionMutation(addToCart, {
93
+ * onSuccess: (data) => {
94
+ * toastSuccessMessage('Added to cart!')
95
+ * queryClient.invalidateQueries({ queryKey: ['cart-items'] })
96
+ * },
97
+ * onError: (error) => {
98
+ * toastActionError(error, 'Failed to add to cart')
99
+ * }
100
+ * })
101
+ * ```
102
+ */
51
103
  export function useSafeActionMutation<TAction extends AnyAction>(
52
104
  action: TAction,
53
- options?: UseMutationOptions<
54
- ActionData<TAction>,
55
- SafeActionError,
56
- ActionInput<TAction>
57
- >
105
+ options?: UseMutationOptions<ActionData<TAction>, Error, ActionInput<TAction>>
58
106
  ) {
59
107
  return useMutation({
60
108
  mutationFn: async (input: ActionInput<TAction>) => {
@@ -65,7 +113,10 @@ export function useSafeActionMutation<TAction extends AnyAction>(
65
113
  }
66
114
 
67
115
  if (result?.validationErrors) {
68
- throw new SafeActionError('Validation failed', result.validationErrors)
116
+ throw new SafeActionError(
117
+ 'Check the highlighted fields and try again.',
118
+ result.validationErrors
119
+ )
69
120
  }
70
121
 
71
122
  return result?.data