create-ej-app 26.0.0 → 28.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +79 -29
  3. package/package.json +2 -2
  4. package/template/api/.vscode/settings.json +1 -1
  5. package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
  6. package/template/api/CLAUDE.md +1 -0
  7. package/template/api/package.json +10 -10
  8. package/template/nextjs/.dockerignore +1 -0
  9. package/template/nextjs/AGENTS.md +260 -0
  10. package/template/nextjs/CLAUDE.md +1 -0
  11. package/template/nextjs/README.md +109 -0
  12. package/template/nextjs/bunfig.toml +3 -0
  13. package/template/nextjs/eslint.config.mjs +6 -5
  14. package/template/nextjs/gitignore +6 -1
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -39
  17. package/template/nextjs/public/logo-dark.svg +4 -0
  18. package/template/nextjs/public/logo.svg +4 -0
  19. package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
  20. package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  21. package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  22. package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  23. package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
  24. package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
  25. package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
  26. package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  27. package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  28. package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  29. package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  30. package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  31. package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
  32. package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
  33. package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
  34. package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
  35. package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  36. package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
  37. package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
  38. package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
  39. package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
  40. package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
  41. package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
  42. package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
  43. package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
  44. package/template/nextjs/src/app/error.tsx +37 -0
  45. package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  46. package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  47. package/template/nextjs/src/app/fonts/index.ts +60 -0
  48. package/template/nextjs/src/app/global-error.tsx +38 -0
  49. package/template/nextjs/src/app/globals.css +29 -0
  50. package/template/nextjs/src/app/layout.tsx +45 -18
  51. package/template/nextjs/src/app/not-found.tsx +25 -3
  52. package/template/nextjs/src/app/robots.ts +29 -0
  53. package/template/nextjs/src/app/sitemap.ts +21 -0
  54. package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
  55. package/template/nextjs/src/components/dashboard-header.tsx +70 -0
  56. package/template/nextjs/src/components/data-table/enum.ts +5 -0
  57. package/template/nextjs/src/components/data-table/features.ts +49 -0
  58. package/template/nextjs/src/components/data-table/index.tsx +961 -0
  59. package/template/nextjs/src/components/data-table/types.ts +76 -0
  60. package/template/nextjs/src/components/error.tsx +71 -0
  61. package/template/nextjs/src/components/loader.tsx +18 -2
  62. package/template/nextjs/src/components/logo.tsx +49 -0
  63. package/template/nextjs/src/components/navigation-search.tsx +140 -0
  64. package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
  65. package/template/nextjs/src/components/page-heading.tsx +38 -0
  66. package/template/nextjs/src/components/react-query-provider.tsx +41 -2
  67. package/template/nextjs/src/components/route-error.tsx +55 -0
  68. package/template/nextjs/src/components/screen-size.tsx +36 -0
  69. package/template/nextjs/src/components/site-footer.tsx +20 -4
  70. package/template/nextjs/src/components/theme-provider.tsx +48 -0
  71. package/template/nextjs/src/components/theme-toggle.tsx +58 -5
  72. package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
  73. package/template/nextjs/src/components/ui/avatar.tsx +112 -0
  74. package/template/nextjs/src/components/ui/badge.tsx +54 -0
  75. package/template/nextjs/src/components/ui/button.tsx +3 -1
  76. package/template/nextjs/src/components/ui/card.tsx +4 -4
  77. package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
  78. package/template/nextjs/src/components/ui/command.tsx +192 -0
  79. package/template/nextjs/src/components/ui/dialog.tsx +164 -0
  80. package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
  81. package/template/nextjs/src/components/ui/input-group.tsx +156 -0
  82. package/template/nextjs/src/components/ui/select.tsx +195 -0
  83. package/template/nextjs/src/components/ui/sheet.tsx +143 -0
  84. package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
  85. package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
  86. package/template/nextjs/src/components/ui/table.tsx +116 -0
  87. package/template/nextjs/src/components/ui/textarea.tsx +18 -0
  88. package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
  89. package/template/nextjs/src/components/user-menu.tsx +206 -0
  90. package/template/nextjs/src/db/common.ts +10 -0
  91. package/template/nextjs/src/db/index.ts +16 -6
  92. package/template/nextjs/src/db/relations.ts +21 -0
  93. package/template/nextjs/src/db/schema.ts +85 -46
  94. package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
  95. package/template/nextjs/src/emails/magic-link.tsx +15 -13
  96. package/template/nextjs/src/emails/test.tsx +11 -9
  97. package/template/nextjs/src/emails/welcome.tsx +12 -9
  98. package/template/nextjs/src/env.ts +1 -1
  99. package/template/nextjs/src/hooks/use-mobile.ts +14 -12
  100. package/template/nextjs/src/instrumentation.ts +20 -2
  101. package/template/nextjs/src/lib/actions.ts +55 -0
  102. package/template/nextjs/src/lib/admin-navigation.ts +43 -0
  103. package/template/nextjs/src/lib/auth-client.ts +20 -9
  104. package/template/nextjs/src/lib/auth-permissions.ts +61 -0
  105. package/template/nextjs/src/lib/auth.ts +33 -3
  106. package/template/nextjs/src/lib/callback-url.ts +28 -0
  107. package/template/nextjs/src/lib/constants.ts +21 -0
  108. package/template/nextjs/src/lib/delay.ts +10 -0
  109. package/template/nextjs/src/lib/email-service.ts +21 -0
  110. package/template/nextjs/src/lib/errors.ts +74 -0
  111. package/template/nextjs/src/lib/format-currency.ts +22 -24
  112. package/template/nextjs/src/lib/format-date.ts +69 -6
  113. package/template/nextjs/src/lib/nodemailer.ts +27 -3
  114. package/template/nextjs/src/lib/otel.ts +92 -2
  115. package/template/nextjs/src/lib/safe-action-client.ts +60 -9
  116. package/template/nextjs/src/lib/safe-action.ts +152 -16
  117. package/template/nextjs/src/lib/search.ts +16 -0
  118. package/template/nextjs/src/lib/seo.ts +7 -0
  119. package/template/nextjs/src/lib/siteConfig.ts +17 -1
  120. package/template/nextjs/src/lib/toast-message.ts +87 -1
  121. package/template/nextjs/src/lib/validations.ts +30 -0
  122. package/template/nextjs/src/proxy.ts +48 -0
  123. package/template/nextjs/src/scripts/reset-db.ts +22 -0
  124. package/template/nextjs/src/scripts/seed.ts +59 -8
  125. package/template/nextjs/src/scripts/test-mail.ts +28 -20
  126. package/template/nextjs/tests/admin/users.test.ts +64 -0
  127. package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
  128. package/template/nextjs/tests/db/seed.test.ts +49 -0
  129. package/template/nextjs/tests/helpers/action.ts +71 -0
  130. package/template/nextjs/tests/helpers/fixtures.ts +78 -0
  131. package/template/nextjs/tests/helpers/index.ts +2 -0
  132. package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
  133. package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
  134. package/template/nextjs/tests/lib/errors.test.ts +10 -0
  135. package/template/nextjs/tests/lib/search.test.ts +23 -0
  136. package/template/nextjs/tests/setup/pglite-db.ts +63 -0
  137. package/template/nextjs/tests/setup/preload.ts +95 -0
  138. package/template/nextjs/tsconfig.json +1 -0
  139. package/template/saas-starter/.dockerignore +1 -0
  140. package/template/saas-starter/.env.example +15 -15
  141. package/template/saas-starter/AGENTS.md +302 -0
  142. package/template/saas-starter/CLAUDE.md +1 -0
  143. package/template/saas-starter/README.md +124 -2
  144. package/template/saas-starter/bunfig.toml +3 -0
  145. package/template/saas-starter/eslint.config.mjs +6 -5
  146. package/template/saas-starter/gitignore +6 -1
  147. package/template/saas-starter/next.config.ts +19 -1
  148. package/template/saas-starter/package.json +50 -44
  149. package/template/saas-starter/public/logo-dark.svg +4 -0
  150. package/template/saas-starter/public/logo.svg +4 -0
  151. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  152. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  153. package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
  154. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
  155. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
  156. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  157. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  158. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
  159. package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
  160. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  161. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  162. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  163. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
  164. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  165. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  166. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  167. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  168. package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
  169. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  170. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  171. package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
  172. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  173. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  174. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
  175. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
  176. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
  177. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
  178. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
  179. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
  180. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
  181. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  182. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  188. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
  189. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
  190. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
  191. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  192. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
  193. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
  194. package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
  195. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
  196. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
  197. package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
  198. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  199. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  200. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  201. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  202. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  203. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
  204. package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
  205. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
  206. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
  207. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
  208. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  209. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  210. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  211. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  212. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  213. package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
  214. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  215. package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
  216. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
  217. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
  218. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  219. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  220. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  221. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  222. package/template/saas-starter/src/app/error.tsx +37 -0
  223. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  224. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  225. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  226. package/template/saas-starter/src/app/global-error.tsx +38 -0
  227. package/template/saas-starter/src/app/globals.css +29 -0
  228. package/template/saas-starter/src/app/layout.tsx +43 -19
  229. package/template/saas-starter/src/app/not-found.tsx +25 -3
  230. package/template/saas-starter/src/app/robots.ts +14 -10
  231. package/template/saas-starter/src/app/sitemap.ts +2 -1
  232. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  233. package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
  234. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  235. package/template/saas-starter/src/components/data-table/index.tsx +65 -64
  236. package/template/saas-starter/src/components/data-table/types.ts +5 -4
  237. package/template/saas-starter/src/components/error.tsx +45 -0
  238. package/template/saas-starter/src/components/file-upload.tsx +65 -26
  239. package/template/saas-starter/src/components/loader.tsx +18 -2
  240. package/template/saas-starter/src/components/logo.tsx +49 -0
  241. package/template/saas-starter/src/components/navigation-search.tsx +2 -2
  242. package/template/saas-starter/src/components/page-heading.tsx +6 -0
  243. package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
  244. package/template/saas-starter/src/components/route-error.tsx +55 -0
  245. package/template/saas-starter/src/components/screen-size.tsx +36 -0
  246. package/template/saas-starter/src/components/site-footer.tsx +20 -4
  247. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  248. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  249. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  250. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  251. package/template/saas-starter/src/components/theme-provider.tsx +48 -0
  252. package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
  253. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  254. package/template/saas-starter/src/components/ui/badge.tsx +6 -1
  255. package/template/saas-starter/src/components/ui/button.tsx +2 -0
  256. package/template/saas-starter/src/components/ui/command.tsx +1 -1
  257. package/template/saas-starter/src/components/user-menu.tsx +147 -24
  258. package/template/saas-starter/src/db/common.ts +33 -1
  259. package/template/saas-starter/src/db/enums.ts +24 -0
  260. package/template/saas-starter/src/db/index.ts +14 -5
  261. package/template/saas-starter/src/db/relations.ts +40 -3
  262. package/template/saas-starter/src/db/schema.ts +186 -35
  263. package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
  264. package/template/saas-starter/src/emails/invitation.tsx +34 -21
  265. package/template/saas-starter/src/emails/magic-link.tsx +15 -13
  266. package/template/saas-starter/src/emails/test.tsx +11 -9
  267. package/template/saas-starter/src/emails/welcome.tsx +12 -9
  268. package/template/saas-starter/src/env.ts +1 -1
  269. package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
  270. package/template/saas-starter/src/instrumentation.ts +20 -2
  271. package/template/saas-starter/src/lib/actions.ts +34 -0
  272. package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
  273. package/template/saas-starter/src/lib/app-navigation.ts +45 -41
  274. package/template/saas-starter/src/lib/auth-client.ts +8 -3
  275. package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
  276. package/template/saas-starter/src/lib/auth.ts +122 -21
  277. package/template/saas-starter/src/lib/billing.ts +59 -24
  278. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  279. package/template/saas-starter/src/lib/constants.ts +78 -9
  280. package/template/saas-starter/src/lib/delay.ts +10 -0
  281. package/template/saas-starter/src/lib/email-service.ts +30 -1
  282. package/template/saas-starter/src/lib/errors.ts +74 -0
  283. package/template/saas-starter/src/lib/format-currency.ts +22 -24
  284. package/template/saas-starter/src/lib/format-date.ts +69 -6
  285. package/template/saas-starter/src/lib/image-url.ts +54 -0
  286. package/template/saas-starter/src/lib/nodemailer.ts +27 -3
  287. package/template/saas-starter/src/lib/organization-access.ts +56 -76
  288. package/template/saas-starter/src/lib/otel.ts +92 -2
  289. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  290. package/template/saas-starter/src/lib/plans.ts +40 -13
  291. package/template/saas-starter/src/lib/rbac.ts +44 -0
  292. package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
  293. package/template/saas-starter/src/lib/safe-action.ts +137 -17
  294. package/template/saas-starter/src/lib/search.ts +16 -0
  295. package/template/saas-starter/src/lib/seo.ts +7 -0
  296. package/template/saas-starter/src/lib/siteConfig.ts +17 -1
  297. package/template/saas-starter/src/lib/storage.ts +176 -46
  298. package/template/saas-starter/src/lib/stripe.ts +4 -1
  299. package/template/saas-starter/src/lib/toast-message.ts +87 -1
  300. package/template/saas-starter/src/lib/validations.ts +85 -2
  301. package/template/saas-starter/src/proxy.ts +48 -0
  302. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  303. package/template/saas-starter/src/scripts/seed.ts +82 -8
  304. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  305. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
  306. package/template/saas-starter/src/scripts/test-mail.ts +28 -20
  307. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  308. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  309. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  310. package/template/saas-starter/tests/helpers/action.ts +71 -0
  311. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  312. package/template/saas-starter/tests/helpers/index.ts +2 -0
  313. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  314. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  315. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  316. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  317. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  318. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  319. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  320. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  321. package/template/saas-starter/tests/setup/preload.ts +108 -0
  322. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  323. package/template/saas-starter/tsconfig.json +1 -0
  324. package/cli.js +0 -147
  325. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  326. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  327. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  328. package/template/nextjs/terraform/backend-service.tf +0 -13
  329. package/template/nextjs/terraform/cleanup.sh +0 -9
  330. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  331. package/template/nextjs/terraform/cloudrun.tf +0 -68
  332. package/template/nextjs/terraform/database.tf +0 -60
  333. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  334. package/template/nextjs/terraform/gitignore +0 -4
  335. package/template/nextjs/terraform/ip-address.tf +0 -11
  336. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  337. package/template/nextjs/terraform/network.tf +0 -21
  338. package/template/nextjs/terraform/pre.sh +0 -3
  339. package/template/nextjs/terraform/providers.tf +0 -18
  340. package/template/nextjs/terraform/secret.txt +0 -11
  341. package/template/nextjs/terraform/setup.sh +0 -25
  342. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  343. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  344. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  345. package/template/nextjs/terraform/terraform.tfvars +0 -4
  346. package/template/nextjs/terraform/url-map.tf +0 -23
  347. package/template/nextjs/terraform/variables.tf +0 -21
  348. package/template/saas-starter/next-env.d.ts +0 -6
  349. package/template/saas-starter/src/actions/uploads.ts +0 -28
  350. package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
  351. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
  352. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
  353. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
  354. package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
  355. package/template/saas-starter/src/app/proxy.ts +0 -28
  356. package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
  357. package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
@@ -1,31 +1,93 @@
1
+ /**
2
+ * Social authentication button components
3
+ *
4
+ * Provides OAuth sign-in buttons for Google and GitHub.
5
+ */
6
+
1
7
  'use client'
2
8
 
9
+ import { useState, useSyncExternalStore } from 'react'
3
10
  import { Button } from '~/components/ui/button'
4
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 = () => () => {}
5
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
+ */
6
31
  export const SocialLoginButtons = ({
7
32
  callbackUrl
8
33
  }: {
9
34
  callbackUrl?: string | null
10
35
  }) => {
11
- const lastUsedLoginMethod = getLastUsedLoginMethod()
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
+ }
12
76
 
13
77
  return (
14
- <div className="flex flex-col space-y-2">
78
+ <div className="flex flex-col gap-2">
15
79
  <Button
80
+ type="button"
16
81
  variant="outline"
17
- onClick={async () => {
18
- await signIn.social({
19
- provider: 'google',
20
- callbackURL: callbackUrl || '/login'
21
- })
22
- }}
82
+ disabled={isPending}
83
+ onClick={() => startOAuth('google')}
23
84
  >
24
85
  <svg
25
86
  viewBox="0 0 24 24"
26
87
  width="16"
27
88
  height="16"
28
89
  xmlns="http://www.w3.org/2000/svg"
90
+ aria-hidden
29
91
  >
30
92
  <g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
31
93
  <path
@@ -46,29 +108,29 @@ export const SocialLoginButtons = ({
46
108
  />
47
109
  </g>
48
110
  </svg>
49
- Continue with Google
50
- {lastUsedLoginMethod === 'google' ? ' (last used)' : ''}
111
+ {pendingProvider === 'google'
112
+ ? 'Continuing with Google…'
113
+ : `Continue with Google${lastUsedLoginMethod === 'google' ? ' (last used)' : ''}`}
51
114
  </Button>
52
115
 
53
116
  <Button
117
+ type="button"
54
118
  variant="outline"
55
- onClick={async () => {
56
- await signIn.social({
57
- provider: 'github',
58
- callbackURL: callbackUrl || '/login'
59
- })
60
- }}
119
+ disabled={isPending}
120
+ onClick={() => startOAuth('github')}
61
121
  >
62
122
  <svg
63
123
  viewBox="0 0 24 24"
64
124
  width="16"
65
125
  height="16"
66
126
  xmlns="http://www.w3.org/2000/svg"
127
+ aria-hidden
67
128
  >
68
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" />
69
130
  </svg>
70
- Continue with GitHub
71
- {lastUsedLoginMethod === 'github' ? ' (last used)' : ''}
131
+ {pendingProvider === 'github'
132
+ ? 'Continuing with GitHub…'
133
+ : `Continue with GitHub${lastUsedLoginMethod === 'github' ? ' (last used)' : ''}`}
72
134
  </Button>
73
135
  </div>
74
136
  )
@@ -3,9 +3,13 @@
3
3
  import Link from 'next/link'
4
4
  import { useRouter } from 'next/navigation'
5
5
  import { useState } from 'react'
6
- import { toast } from 'sonner'
7
6
  import { Button } from '~/components/ui/button'
8
7
  import { organization, signOut } from '~/lib/auth-client'
8
+ import {
9
+ toastActionError,
10
+ toastInfoMessage,
11
+ toastSuccessMessage
12
+ } from '~/lib/toast-message'
9
13
 
10
14
  export function AcceptInvitationForm({
11
15
  invitationId,
@@ -22,11 +26,11 @@ export function AcceptInvitationForm({
22
26
  setAccepting(true)
23
27
  const { error } = await organization.acceptInvitation({ invitationId })
24
28
  if (error) {
25
- toast.error(error.message ?? 'Failed to accept invitation')
29
+ toastActionError(error, 'The invitation was not accepted. Try again.')
26
30
  setAccepting(false)
27
31
  return
28
32
  }
29
- toast.success(`You've joined ${organizationName}`)
33
+ toastSuccessMessage(`You joined ${organizationName}`)
30
34
  router.push('/app')
31
35
  }
32
36
 
@@ -34,11 +38,11 @@ export function AcceptInvitationForm({
34
38
  setDeclining(true)
35
39
  const { error } = await organization.rejectInvitation({ invitationId })
36
40
  if (error) {
37
- toast.error(error.message ?? 'Failed to decline invitation')
41
+ toastActionError(error, 'The invitation was not declined. Try again.')
38
42
  setDeclining(false)
39
43
  return
40
44
  }
41
- toast('Invitation declined')
45
+ toastInfoMessage('Invitation declined')
42
46
  router.push('/')
43
47
  }
44
48
 
@@ -47,7 +51,7 @@ export function AcceptInvitationForm({
47
51
  return (
48
52
  <div className="flex flex-col gap-3">
49
53
  <Button onClick={handleAccept} disabled={disabled} className="w-full">
50
- {accepting ? 'Accepting...' : 'Accept invitation'}
54
+ {accepting ? 'Accepting…' : 'Accept Invitation'}
51
55
  </Button>
52
56
  <Button
53
57
  onClick={handleDecline}
@@ -55,7 +59,7 @@ export function AcceptInvitationForm({
55
59
  variant="outline"
56
60
  className="w-full"
57
61
  >
58
- {declining ? 'Declining...' : 'Decline'}
62
+ {declining ? 'Declining…' : 'Decline'}
59
63
  </Button>
60
64
  </div>
61
65
  )
@@ -72,16 +76,18 @@ export function WrongAccountActions({
72
76
  async function handleSwitch() {
73
77
  setLoading(true)
74
78
  await signOut()
75
- router.push(`/login?callbackUrl=/accept-invitation/${invitationId}`)
79
+ router.push(
80
+ `/login?callbackUrl=${encodeURIComponent(`/accept-invitation/${invitationId}`)}`
81
+ )
76
82
  }
77
83
 
78
84
  return (
79
85
  <div className="flex justify-center gap-3">
80
86
  <Button asChild variant="outline">
81
- <Link href="/">Return home</Link>
87
+ <Link href="/">Go Home</Link>
82
88
  </Button>
83
89
  <Button onClick={handleSwitch} disabled={loading}>
84
- {loading ? 'Signing out...' : 'Switch account'}
90
+ {loading ? 'Signing out…' : 'Switch Account'}
85
91
  </Button>
86
92
  </div>
87
93
  )
@@ -1,5 +1,5 @@
1
- import { eq } from 'drizzle-orm'
2
1
  import { Building2 } from 'lucide-react'
2
+ import type { Metadata } from 'next'
3
3
  import Link from 'next/link'
4
4
  import { Button } from '~/components/ui/button'
5
5
  import {
@@ -10,14 +10,20 @@ import {
10
10
  CardHeader,
11
11
  CardTitle
12
12
  } from '~/components/ui/card'
13
- import { db } from '~/db'
14
- import { invitationsTable } from '~/db/schema'
15
13
  import { getAuthSession } from '~/lib/auth'
14
+ import { roleArticle } from '~/lib/rbac'
15
+ import { privatePageRobots } from '~/lib/seo'
16
+ import { getInvitation } from '../../actions/invitations'
16
17
  import {
17
18
  AcceptInvitationForm,
18
19
  WrongAccountActions
19
20
  } from './accept-invitation-form'
20
21
 
22
+ export const metadata: Metadata = {
23
+ title: 'Accept Invitation',
24
+ robots: privatePageRobots
25
+ }
26
+
21
27
  export default async function AcceptInvitationPage({
22
28
  params
23
29
  }: {
@@ -25,44 +31,22 @@ export default async function AcceptInvitationPage({
25
31
  }) {
26
32
  const { id } = await params
27
33
 
28
- const invitation = await db.query.invitationsTable.findFirst({
29
- where: eq(invitationsTable.id, id),
30
- with: {
31
- organization: {
32
- columns: {
33
- id: true,
34
- name: true
35
- }
36
- },
37
- user: {
38
- columns: {
39
- id: true,
40
- name: true
41
- }
42
- }
43
- },
44
- columns: {
45
- id: true,
46
- status: true,
47
- expiresAt: true,
48
- email: true,
49
- role: true
50
- }
51
- })
34
+ const invitation = (await getInvitation(id))?.data
52
35
 
53
36
  if (!invitation || invitation.status !== 'pending') {
54
37
  return (
55
38
  <main className="flex min-h-screen items-center justify-center px-4 py-12">
56
39
  <Card className="w-full max-w-md text-center">
57
40
  <CardHeader>
58
- <CardTitle>Invitation not found</CardTitle>
41
+ <CardTitle>Invitation Not Found</CardTitle>
59
42
  <CardDescription>
60
- This invitation link is invalid or has already been used.
43
+ This invitation link is invalid or was already used. Ask the
44
+ organization admin to send a new one.
61
45
  </CardDescription>
62
46
  </CardHeader>
63
47
  <CardFooter className="justify-center">
64
48
  <Button asChild variant="outline">
65
- <Link href="/">Return home</Link>
49
+ <Link href="/">Go Home</Link>
66
50
  </Button>
67
51
  </CardFooter>
68
52
  </Card>
@@ -75,15 +59,15 @@ export default async function AcceptInvitationPage({
75
59
  <main className="flex min-h-screen items-center justify-center px-4 py-12">
76
60
  <Card className="w-full max-w-md text-center">
77
61
  <CardHeader>
78
- <CardTitle>Invitation expired</CardTitle>
62
+ <CardTitle>Invitation Expired</CardTitle>
79
63
  <CardDescription>
80
64
  This invitation to <strong>{invitation.organization.name}</strong>{' '}
81
- has expired. Ask your organization admin for a new one.
65
+ expired. Ask the organization admin to send a new one.
82
66
  </CardDescription>
83
67
  </CardHeader>
84
68
  <CardFooter className="justify-center">
85
69
  <Button asChild variant="outline">
86
- <Link href="/">Return home</Link>
70
+ <Link href="/">Go Home</Link>
87
71
  </Button>
88
72
  </CardFooter>
89
73
  </Card>
@@ -101,17 +85,19 @@ export default async function AcceptInvitationPage({
101
85
  <div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
102
86
  <Building2 className="text-primary size-6" />
103
87
  </div>
104
- <CardTitle>You&apos;ve been invited</CardTitle>
88
+ <CardTitle>You&apos;re Invited</CardTitle>
105
89
  <CardDescription>
106
- <strong>{invitation.user.name}</strong> invited you to join{' '}
90
+ <strong>{invitation.inviter.name}</strong> invited you to join{' '}
107
91
  <strong>{invitation.organization.name}</strong>. Sign in to
108
92
  accept.
109
93
  </CardDescription>
110
94
  </CardHeader>
111
95
  <CardContent>
112
96
  <Button asChild className="w-full">
113
- <Link href={`/login?callbackUrl=/accept-invitation/${id}`}>
114
- Sign in to accept
97
+ <Link
98
+ href={`/login?callbackUrl=${encodeURIComponent(`/accept-invitation/${id}`)}`}
99
+ >
100
+ Sign In to Accept
115
101
  </Link>
116
102
  </Button>
117
103
  </CardContent>
@@ -125,12 +111,12 @@ export default async function AcceptInvitationPage({
125
111
  <main className="flex min-h-screen items-center justify-center px-4 py-12">
126
112
  <Card className="w-full max-w-md text-center">
127
113
  <CardHeader>
128
- <CardTitle>Wrong account</CardTitle>
114
+ <CardTitle>Wrong Account</CardTitle>
129
115
  <CardDescription>
130
116
  This invitation was sent to <strong>{invitation.email}</strong>{' '}
131
117
  but you&apos;re signed in as{' '}
132
- <strong>{authSession.user.email}</strong>. Sign in with the
133
- correct account to accept.
118
+ <strong>{authSession.user.email}</strong>. Switch accounts to
119
+ accept it.
134
120
  </CardDescription>
135
121
  </CardHeader>
136
122
  <CardFooter className="justify-center">
@@ -150,9 +136,9 @@ export default async function AcceptInvitationPage({
150
136
  </div>
151
137
  <CardTitle>Join {invitation.organization.name}</CardTitle>
152
138
  <CardDescription>
153
- <strong>{invitation.user.name}</strong> has invited you to join{' '}
139
+ <strong>{invitation.inviter.name}</strong> invited you to join{' '}
154
140
  <strong>{invitation.organization.name}</strong>
155
- {invitation.role ? ` as ${invitation.role}` : ''}.
141
+ {invitation.role ? ` as ${roleArticle(invitation.role)}` : ''}.
156
142
  </CardDescription>
157
143
  </CardHeader>
158
144
  <CardContent>
@@ -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
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod'
2
+ import { MAX_MESSAGE_LENGTH, MIN_CONTACT_MESSAGE_LENGTH } from '~/lib/constants'
3
+ import {
4
+ emailValidation,
5
+ phoneValidation,
6
+ stringValidation
7
+ } from '~/lib/validations'
8
+
9
+ export const contactFormSchema = z.object({
10
+ name: stringValidation,
11
+ email: emailValidation,
12
+ phone: phoneValidation.or(z.literal('')).optional(),
13
+ subject: stringValidation,
14
+ message: z
15
+ .string()
16
+ .trim()
17
+ .min(
18
+ MIN_CONTACT_MESSAGE_LENGTH,
19
+ `Write at least ${MIN_CONTACT_MESSAGE_LENGTH} characters.`
20
+ )
21
+ .max(MAX_MESSAGE_LENGTH)
22
+ })
@@ -1,14 +1,48 @@
1
+ import Link from 'next/link'
2
+ import { Logo } from '~/components/logo'
1
3
  import { SiteFooter } from '~/components/site-footer'
4
+ import { Button } from '~/components/ui/button'
5
+ import { siteConfig } from '~/lib/siteConfig'
2
6
 
3
- export default async function Layout({
7
+ export default function Layout({
4
8
  children
5
9
  }: Readonly<{
6
10
  children: React.ReactNode
7
11
  }>) {
8
12
  return (
9
- <div className="flex min-h-screen flex-col space-y-4">
10
- <main className="flex-1">{children}</main>
11
- <SiteFooter />
13
+ <div className="bg-background text-foreground flex min-h-screen flex-col">
14
+ <a
15
+ href="#main-content"
16
+ className="bg-background text-foreground focus:ring-ring/50 sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-200 focus:rounded-md focus:px-4 focus:py-2 focus:ring-3"
17
+ >
18
+ Skip to content
19
+ </a>
20
+ <header className="bg-background/80 sticky top-0 z-50 border-b backdrop-blur">
21
+ <nav
22
+ aria-label="Primary"
23
+ className="container flex h-14 items-center justify-between gap-4"
24
+ >
25
+ <Link href="/" className="flex items-center">
26
+ <Logo size={28} priority className="text-lg" />
27
+ </Link>
28
+ <div className="flex items-center gap-2">
29
+ <Button asChild variant="ghost" size="sm">
30
+ <Link href="/contact-us">Contact</Link>
31
+ </Button>
32
+ <Button asChild size="sm">
33
+ <Link href="/login">Log In</Link>
34
+ </Button>
35
+ </div>
36
+ </nav>
37
+ </header>
38
+ <main
39
+ id="main-content"
40
+ tabIndex={-1}
41
+ className="focus-visible:outline-primary flex-1 focus-visible:outline-2 focus-visible:-outline-offset-2"
42
+ >
43
+ {children}
44
+ </main>
45
+ <SiteFooter name={siteConfig.name} />
12
46
  </div>
13
47
  )
14
48
  }