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
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @fileoverview Login/Sign-in Page
3
+ *
4
+ * Authentication entry point supporting multiple sign-in methods:
5
+ * - Email with magic link (passwordless)
6
+ * - OAuth (Google, GitHub)
7
+ *
8
+ * **Behavior:**
9
+ * - Redirects to /admin for admin users
10
+ * - Redirects to /app for regular authenticated users
11
+ * - Shows login form for anonymous users
12
+ *
13
+ * @module app/(auth)/login/page
14
+ */
15
+
16
+ import Link from 'next/link'
17
+ import { redirect } from 'next/navigation'
18
+ import { Logo } from '~/components/logo'
19
+ import {
20
+ Card,
21
+ CardContent,
22
+ CardDescription,
23
+ CardFooter,
24
+ CardHeader,
25
+ CardTitle
26
+ } from '~/components/ui/card'
27
+ import { Separator } from '~/components/ui/separator'
28
+ import { getAuthSession } from '~/lib/auth'
29
+ import { sanitizeCallbackUrl } from '~/lib/callback-url'
30
+ import { EmailLoginForm } from './email-login-form'
31
+ import { SocialLoginButtons } from './social-login-buttons'
32
+
33
+ /**
34
+ * Login Page Component
35
+ *
36
+ * Renders authentication page with multiple login options.
37
+ * Checks session and redirects already-authenticated users.
38
+ *
39
+ * @returns {JSX.Element} Login form with email and OAuth options
40
+ */
41
+ export default async function LoginPage({
42
+ searchParams
43
+ }: {
44
+ searchParams?: Promise<Record<string, string | string[] | undefined>>
45
+ }) {
46
+ const authSession = await getAuthSession()
47
+ const resolvedParams = (await searchParams) ?? {}
48
+ const callbackUrl = sanitizeCallbackUrl(resolvedParams.callbackUrl)
49
+
50
+ if (authSession?.isAdmin) {
51
+ return redirect(callbackUrl ?? '/admin')
52
+ } else if (authSession) {
53
+ return redirect(callbackUrl ?? '/app')
54
+ }
55
+
56
+ return (
57
+ <div className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
58
+ <Card className="w-full max-w-md">
59
+ <CardHeader className="space-y-1 text-center">
60
+ <Link href="/" className="mx-auto mb-4 inline-flex">
61
+ <Logo size={40} priority className="text-xl" />
62
+ </Link>
63
+ <CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
64
+ <CardDescription className="text-muted-foreground text-sm">
65
+ Sign in to your account to continue
66
+ </CardDescription>
67
+ </CardHeader>
68
+ <CardContent className="space-y-4">
69
+ <div className="border-muted-foreground/50 bg-muted/50 rounded-lg border border-dashed p-4">
70
+ <p className="text-muted-foreground text-sm">
71
+ <strong>New to our site?</strong> No need to create a separate
72
+ account. Use either option below to sign up and log in.
73
+ </p>
74
+ </div>
75
+
76
+ <EmailLoginForm callbackUrl={callbackUrl} />
77
+
78
+ <div className="relative">
79
+ <div className="absolute inset-0 flex items-center">
80
+ <span className="w-full border-t" />
81
+ </div>
82
+ <div className="relative flex justify-center text-xs uppercase">
83
+ <span className="bg-background text-muted-foreground px-2">
84
+ Or continue with
85
+ </span>
86
+ </div>
87
+ </div>
88
+
89
+ <SocialLoginButtons callbackUrl={callbackUrl} />
90
+ </CardContent>
91
+ <CardFooter className="flex flex-col space-y-4">
92
+ <Separator className="my-2" />
93
+ <div className="text-muted-foreground text-center text-sm">
94
+ <Link
95
+ href="/"
96
+ className="text-primary underline-offset-4 hover:underline"
97
+ >
98
+ Return to Home Page
99
+ </Link>
100
+ </div>
101
+ </CardFooter>
102
+ </Card>
103
+ </div>
104
+ )
105
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Social authentication button components
3
+ *
4
+ * Provides OAuth sign-in buttons for Google and GitHub.
5
+ */
6
+
7
+ 'use client'
8
+
9
+ import { useState, useSyncExternalStore } from 'react'
10
+ import { Button } from '~/components/ui/button'
11
+ import { getLastUsedLoginMethod, signIn } from '~/lib/auth-client'
12
+ import { toastActionError, toastErrorMessage } from '~/lib/toast-message'
13
+
14
+ type OAuthProvider = 'google' | 'github'
15
+
16
+ const PROVIDER_LABELS: Record<OAuthProvider, string> = {
17
+ google: 'Google',
18
+ github: 'GitHub'
19
+ }
20
+
21
+ const emptySubscribe = () => () => {}
22
+
23
+ /**
24
+ * Social Login Buttons Component
25
+ *
26
+ * Renders OAuth provider buttons (Google, GitHub).
27
+ * Highlights the last-used login method if available.
28
+ *
29
+ * @returns {JSX.Element} Flex container with OAuth provider buttons
30
+ */
31
+ export const SocialLoginButtons = ({
32
+ callbackUrl
33
+ }: {
34
+ callbackUrl?: string | null
35
+ }) => {
36
+ // Cookie/local storage is only available on the client; server snapshot is null.
37
+ const lastUsedLoginMethod = useSyncExternalStore(
38
+ emptySubscribe,
39
+ () => getLastUsedLoginMethod() ?? null,
40
+ () => null
41
+ )
42
+ const [pendingProvider, setPendingProvider] = useState<OAuthProvider | null>(
43
+ null
44
+ )
45
+
46
+ const target = callbackUrl || '/login'
47
+ const isPending = pendingProvider !== null
48
+
49
+ async function startOAuth(provider: OAuthProvider) {
50
+ if (pendingProvider) return
51
+
52
+ setPendingProvider(provider)
53
+ try {
54
+ const res = await signIn.social({
55
+ provider,
56
+ callbackURL: target
57
+ })
58
+ if (res?.error) throw res.error
59
+ } catch (err) {
60
+ console.error(`Error starting ${provider} sign-in:`, err)
61
+ if (
62
+ typeof err === 'object' &&
63
+ err &&
64
+ 'message' in err &&
65
+ typeof err.message === 'string'
66
+ ) {
67
+ toastActionError(err)
68
+ } else {
69
+ toastErrorMessage(
70
+ `Sign-in with ${PROVIDER_LABELS[provider]} did not start. Try again.`
71
+ )
72
+ }
73
+ setPendingProvider(null)
74
+ }
75
+ }
76
+
77
+ return (
78
+ <div className="flex flex-col gap-2">
79
+ <Button
80
+ type="button"
81
+ variant="outline"
82
+ disabled={isPending}
83
+ onClick={() => startOAuth('google')}
84
+ >
85
+ <svg
86
+ viewBox="0 0 24 24"
87
+ width="16"
88
+ height="16"
89
+ xmlns="http://www.w3.org/2000/svg"
90
+ aria-hidden
91
+ >
92
+ <g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
93
+ <path
94
+ fill="#4285F4"
95
+ d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
96
+ />
97
+ <path
98
+ fill="#34A853"
99
+ d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
100
+ />
101
+ <path
102
+ fill="#FBBC05"
103
+ d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
104
+ />
105
+ <path
106
+ fill="#EA4335"
107
+ d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
108
+ />
109
+ </g>
110
+ </svg>
111
+ {pendingProvider === 'google'
112
+ ? 'Continuing with Google…'
113
+ : `Continue with Google${lastUsedLoginMethod === 'google' ? ' (last used)' : ''}`}
114
+ </Button>
115
+
116
+ <Button
117
+ type="button"
118
+ variant="outline"
119
+ disabled={isPending}
120
+ onClick={() => startOAuth('github')}
121
+ >
122
+ <svg
123
+ viewBox="0 0 24 24"
124
+ width="16"
125
+ height="16"
126
+ xmlns="http://www.w3.org/2000/svg"
127
+ aria-hidden
128
+ >
129
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
130
+ </svg>
131
+ {pendingProvider === 'github'
132
+ ? 'Continuing with GitHub…'
133
+ : `Continue with GitHub${lastUsedLoginMethod === 'github' ? ' (last used)' : ''}`}
134
+ </Button>
135
+ </div>
136
+ )
137
+ }
@@ -0,0 +1,94 @@
1
+ 'use client'
2
+
3
+ import Link from 'next/link'
4
+ import { useRouter } from 'next/navigation'
5
+ import { useState } from 'react'
6
+ import { Button } from '~/components/ui/button'
7
+ import { organization, signOut } from '~/lib/auth-client'
8
+ import {
9
+ toastActionError,
10
+ toastInfoMessage,
11
+ toastSuccessMessage
12
+ } from '~/lib/toast-message'
13
+
14
+ export function AcceptInvitationForm({
15
+ invitationId,
16
+ organizationName
17
+ }: {
18
+ invitationId: string
19
+ organizationName: string
20
+ }) {
21
+ const router = useRouter()
22
+ const [accepting, setAccepting] = useState(false)
23
+ const [declining, setDeclining] = useState(false)
24
+
25
+ async function handleAccept() {
26
+ setAccepting(true)
27
+ const { error } = await organization.acceptInvitation({ invitationId })
28
+ if (error) {
29
+ toastActionError(error, 'The invitation was not accepted. Try again.')
30
+ setAccepting(false)
31
+ return
32
+ }
33
+ toastSuccessMessage(`You joined ${organizationName}`)
34
+ router.push('/app')
35
+ }
36
+
37
+ async function handleDecline() {
38
+ setDeclining(true)
39
+ const { error } = await organization.rejectInvitation({ invitationId })
40
+ if (error) {
41
+ toastActionError(error, 'The invitation was not declined. Try again.')
42
+ setDeclining(false)
43
+ return
44
+ }
45
+ toastInfoMessage('Invitation declined')
46
+ router.push('/')
47
+ }
48
+
49
+ const disabled = accepting || declining
50
+
51
+ return (
52
+ <div className="flex flex-col gap-3">
53
+ <Button onClick={handleAccept} disabled={disabled} className="w-full">
54
+ {accepting ? 'Accepting…' : 'Accept Invitation'}
55
+ </Button>
56
+ <Button
57
+ onClick={handleDecline}
58
+ disabled={disabled}
59
+ variant="outline"
60
+ className="w-full"
61
+ >
62
+ {declining ? 'Declining…' : 'Decline'}
63
+ </Button>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ export function WrongAccountActions({
69
+ invitationId
70
+ }: {
71
+ invitationId: string
72
+ }) {
73
+ const router = useRouter()
74
+ const [loading, setLoading] = useState(false)
75
+
76
+ async function handleSwitch() {
77
+ setLoading(true)
78
+ await signOut()
79
+ router.push(
80
+ `/login?callbackUrl=${encodeURIComponent(`/accept-invitation/${invitationId}`)}`
81
+ )
82
+ }
83
+
84
+ return (
85
+ <div className="flex justify-center gap-3">
86
+ <Button asChild variant="outline">
87
+ <Link href="/">Go Home</Link>
88
+ </Button>
89
+ <Button onClick={handleSwitch} disabled={loading}>
90
+ {loading ? 'Signing out…' : 'Switch Account'}
91
+ </Button>
92
+ </div>
93
+ )
94
+ }
@@ -0,0 +1,153 @@
1
+ import { Building2 } from 'lucide-react'
2
+ import type { Metadata } from 'next'
3
+ import Link from 'next/link'
4
+ import { Button } from '~/components/ui/button'
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardFooter,
10
+ CardHeader,
11
+ CardTitle
12
+ } from '~/components/ui/card'
13
+ import { getAuthSession } from '~/lib/auth'
14
+ import { roleArticle } from '~/lib/rbac'
15
+ import { privatePageRobots } from '~/lib/seo'
16
+ import { getInvitation } from '../../actions/invitations'
17
+ import {
18
+ AcceptInvitationForm,
19
+ WrongAccountActions
20
+ } from './accept-invitation-form'
21
+
22
+ export const metadata: Metadata = {
23
+ title: 'Accept Invitation',
24
+ robots: privatePageRobots
25
+ }
26
+
27
+ export default async function AcceptInvitationPage({
28
+ params
29
+ }: {
30
+ params: Promise<{ id: string }>
31
+ }) {
32
+ const { id } = await params
33
+
34
+ const invitation = (await getInvitation(id))?.data
35
+
36
+ if (!invitation || invitation.status !== 'pending') {
37
+ return (
38
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
39
+ <Card className="w-full max-w-md text-center">
40
+ <CardHeader>
41
+ <CardTitle>Invitation Not Found</CardTitle>
42
+ <CardDescription>
43
+ This invitation link is invalid or was already used. Ask the
44
+ organization admin to send a new one.
45
+ </CardDescription>
46
+ </CardHeader>
47
+ <CardFooter className="justify-center">
48
+ <Button asChild variant="outline">
49
+ <Link href="/">Go Home</Link>
50
+ </Button>
51
+ </CardFooter>
52
+ </Card>
53
+ </main>
54
+ )
55
+ }
56
+
57
+ if (invitation.expiresAt && invitation.expiresAt < new Date()) {
58
+ return (
59
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
60
+ <Card className="w-full max-w-md text-center">
61
+ <CardHeader>
62
+ <CardTitle>Invitation Expired</CardTitle>
63
+ <CardDescription>
64
+ This invitation to <strong>{invitation.organization.name}</strong>{' '}
65
+ expired. Ask the organization admin to send a new one.
66
+ </CardDescription>
67
+ </CardHeader>
68
+ <CardFooter className="justify-center">
69
+ <Button asChild variant="outline">
70
+ <Link href="/">Go Home</Link>
71
+ </Button>
72
+ </CardFooter>
73
+ </Card>
74
+ </main>
75
+ )
76
+ }
77
+
78
+ const authSession = await getAuthSession()
79
+
80
+ if (!authSession) {
81
+ return (
82
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
83
+ <Card className="w-full max-w-md">
84
+ <CardHeader className="text-center">
85
+ <div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
86
+ <Building2 className="text-primary size-6" />
87
+ </div>
88
+ <CardTitle>You&apos;re Invited</CardTitle>
89
+ <CardDescription>
90
+ <strong>{invitation.inviter.name}</strong> invited you to join{' '}
91
+ <strong>{invitation.organization.name}</strong>. Sign in to
92
+ accept.
93
+ </CardDescription>
94
+ </CardHeader>
95
+ <CardContent>
96
+ <Button asChild className="w-full">
97
+ <Link
98
+ href={`/login?callbackUrl=${encodeURIComponent(`/accept-invitation/${id}`)}`}
99
+ >
100
+ Sign In to Accept
101
+ </Link>
102
+ </Button>
103
+ </CardContent>
104
+ </Card>
105
+ </main>
106
+ )
107
+ }
108
+
109
+ if (authSession.user.email.toLowerCase() !== invitation.email.toLowerCase()) {
110
+ return (
111
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
112
+ <Card className="w-full max-w-md text-center">
113
+ <CardHeader>
114
+ <CardTitle>Wrong Account</CardTitle>
115
+ <CardDescription>
116
+ This invitation was sent to <strong>{invitation.email}</strong>{' '}
117
+ but you&apos;re signed in as{' '}
118
+ <strong>{authSession.user.email}</strong>. Switch accounts to
119
+ accept it.
120
+ </CardDescription>
121
+ </CardHeader>
122
+ <CardFooter className="justify-center">
123
+ <WrongAccountActions invitationId={id} />
124
+ </CardFooter>
125
+ </Card>
126
+ </main>
127
+ )
128
+ }
129
+
130
+ return (
131
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
132
+ <Card className="w-full max-w-md">
133
+ <CardHeader className="text-center">
134
+ <div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
135
+ <Building2 className="text-primary size-6" />
136
+ </div>
137
+ <CardTitle>Join {invitation.organization.name}</CardTitle>
138
+ <CardDescription>
139
+ <strong>{invitation.inviter.name}</strong> invited you to join{' '}
140
+ <strong>{invitation.organization.name}</strong>
141
+ {invitation.role ? ` as ${roleArticle(invitation.role)}` : ''}.
142
+ </CardDescription>
143
+ </CardHeader>
144
+ <CardContent>
145
+ <AcceptInvitationForm
146
+ invitationId={id}
147
+ organizationName={invitation.organization.name}
148
+ />
149
+ </CardContent>
150
+ </Card>
151
+ </main>
152
+ )
153
+ }
@@ -0,0 +1,32 @@
1
+ 'use server'
2
+
3
+ import { eq } from 'drizzle-orm'
4
+ import { db } from '~/db'
5
+ import { invitationsTable } from '~/db/schema'
6
+ import { actionClient, withWideEvent } from '~/lib/safe-action'
7
+ import { stringValidation } from '~/lib/validations'
8
+
9
+ /**
10
+ * Public view of an invitation for the accept page. Invitation IDs are
11
+ * unguessable, and accepting still requires signing in as the invited email.
12
+ */
13
+ export const getInvitation = actionClient.inputSchema(stringValidation).action(
14
+ withWideEvent('getInvitation', async ({ parsedInput: invitationId }) => {
15
+ const invitation = await db.query.invitationsTable.findFirst({
16
+ where: eq(invitationsTable.id, invitationId),
17
+ columns: {
18
+ id: true,
19
+ status: true,
20
+ expiresAt: true,
21
+ email: true,
22
+ role: true
23
+ },
24
+ with: {
25
+ organization: { columns: { name: true } },
26
+ inviter: { columns: { name: true } }
27
+ }
28
+ })
29
+
30
+ return invitation ?? null
31
+ })
32
+ )
@@ -0,0 +1,23 @@
1
+ 'use server'
2
+
3
+ import { db } from '~/db'
4
+ import { contactLeadsTable } from '~/db/schema'
5
+ import { actionClient, withWideEvent } from '~/lib/safe-action'
6
+ import { contactFormSchema } from './validation'
7
+
8
+ /** Public contact form. Submissions appear under Contact Leads in the admin panel. */
9
+ export const submitContactForm = actionClient
10
+ .inputSchema(contactFormSchema)
11
+ .action(
12
+ withWideEvent('submitContactForm', async ({ parsedInput }) => {
13
+ await db.insert(contactLeadsTable).values({
14
+ name: parsedInput.name,
15
+ email: parsedInput.email,
16
+ phone: parsedInput.phone || null,
17
+ subject: parsedInput.subject,
18
+ message: parsedInput.message
19
+ })
20
+
21
+ return true
22
+ })
23
+ )
@@ -0,0 +1,118 @@
1
+ 'use client'
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { Loader2, Send } from 'lucide-react'
5
+ import { Controller, useForm } from 'react-hook-form'
6
+ import { z } from 'zod'
7
+ import { Button } from '~/components/ui/button'
8
+ import { Field, FieldError, FieldLabel } from '~/components/ui/field'
9
+ import { Input } from '~/components/ui/input'
10
+ import { Textarea } from '~/components/ui/textarea'
11
+ import { useSafeActionMutation } from '~/lib/safe-action-client'
12
+ import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
13
+ import { submitContactForm } from './actions'
14
+ import { contactFormSchema } from './validation'
15
+
16
+ type ContactFormValues = z.infer<typeof contactFormSchema>
17
+
18
+ const FIELDS = [
19
+ { name: 'name', label: 'Full name', autoComplete: 'name', type: 'text' },
20
+ {
21
+ name: 'email',
22
+ label: 'Email address',
23
+ autoComplete: 'email',
24
+ type: 'email'
25
+ },
26
+ {
27
+ name: 'phone',
28
+ label: 'Phone number (optional)',
29
+ autoComplete: 'tel',
30
+ type: 'tel'
31
+ },
32
+ { name: 'subject', label: 'Subject', autoComplete: 'off', type: 'text' }
33
+ ] as const
34
+
35
+ export function ContactForm() {
36
+ const form = useForm<ContactFormValues>({
37
+ resolver: zodResolver(contactFormSchema),
38
+ defaultValues: {
39
+ name: '',
40
+ email: '',
41
+ phone: '',
42
+ subject: '',
43
+ message: ''
44
+ }
45
+ })
46
+
47
+ const { mutate, isPending } = useSafeActionMutation(submitContactForm, {
48
+ onSuccess: () => {
49
+ toastSuccessMessage("We received your message. We'll reply by email.")
50
+ form.reset()
51
+ },
52
+ onError: error =>
53
+ toastActionError(
54
+ error,
55
+ "Your message wasn't sent. Check your connection and try again."
56
+ )
57
+ })
58
+
59
+ return (
60
+ <form
61
+ onSubmit={form.handleSubmit(values => mutate(values))}
62
+ className="mt-8 space-y-5"
63
+ aria-busy={isPending}
64
+ >
65
+ {FIELDS.map(item => (
66
+ <Controller
67
+ key={item.name}
68
+ control={form.control}
69
+ name={item.name}
70
+ render={({ field, fieldState }) => (
71
+ <Field data-invalid={fieldState.invalid}>
72
+ <FieldLabel htmlFor={field.name}>{item.label}</FieldLabel>
73
+ <Input
74
+ id={field.name}
75
+ type={item.type}
76
+ autoComplete={item.autoComplete}
77
+ aria-invalid={fieldState.invalid}
78
+ {...field}
79
+ />
80
+ <FieldError
81
+ errors={fieldState.error ? [fieldState.error] : undefined}
82
+ />
83
+ </Field>
84
+ )}
85
+ />
86
+ ))}
87
+
88
+ <Controller
89
+ control={form.control}
90
+ name="message"
91
+ render={({ field, fieldState }) => (
92
+ <Field data-invalid={fieldState.invalid}>
93
+ <FieldLabel htmlFor={field.name}>Message</FieldLabel>
94
+ <Textarea
95
+ id={field.name}
96
+ className="min-h-32"
97
+ placeholder="How can we help…"
98
+ aria-invalid={fieldState.invalid}
99
+ {...field}
100
+ />
101
+ <FieldError
102
+ errors={fieldState.error ? [fieldState.error] : undefined}
103
+ />
104
+ </Field>
105
+ )}
106
+ />
107
+
108
+ <Button type="submit" className="w-full" disabled={isPending}>
109
+ {isPending ? (
110
+ <Loader2 className="animate-spin" aria-hidden="true" />
111
+ ) : (
112
+ <Send aria-hidden="true" />
113
+ )}
114
+ {isPending ? 'Sending…' : 'Send Message'}
115
+ </Button>
116
+ </form>
117
+ )
118
+ }
@@ -0,0 +1,20 @@
1
+ import type { Metadata } from 'next'
2
+ import { siteConfig } from '~/lib/siteConfig'
3
+ import { ContactForm } from './contact-form'
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'Contact Us',
7
+ description: `Send the ${siteConfig.name} team a message.`
8
+ }
9
+
10
+ export default function ContactUsPage() {
11
+ return (
12
+ <div className="container max-w-xl py-12">
13
+ <h1 className="text-3xl font-bold tracking-tight">Contact Us</h1>
14
+ <p className="text-muted-foreground mt-2">
15
+ Send us a message and we&apos;ll reply by email within one business day.
16
+ </p>
17
+ <ContactForm />
18
+ </div>
19
+ )
20
+ }