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,236 @@
1
+ 'use client'
2
+
3
+ import { ArrowLeft, Loader2, Send } from 'lucide-react'
4
+ import Link from 'next/link'
5
+ import { useState } from 'react'
6
+ import {
7
+ addSupportTicketMessageAction,
8
+ getSupportTicketDetailsAction,
9
+ updateSupportTicketStatusAction
10
+ } from '~/app/actions/support-tickets'
11
+ import { Button } from '~/components/ui/button'
12
+ import {
13
+ Select,
14
+ SelectContent,
15
+ SelectItem,
16
+ SelectTrigger,
17
+ SelectValue
18
+ } from '~/components/ui/select'
19
+ import { Textarea } from '~/components/ui/textarea'
20
+ import { formatDate } from '~/lib/format-date'
21
+ import {
22
+ useSafeActionMutation,
23
+ useSafeActionQuery
24
+ } from '~/lib/safe-action-client'
25
+ import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
26
+ import {
27
+ SupportPriorityBadge,
28
+ SupportStatusBadge,
29
+ formatSupportLabel,
30
+ supportStatusOptions,
31
+ type SupportTicketStatus
32
+ } from './support-ticket-shared'
33
+
34
+ export function SupportTicketConversation({
35
+ ticketId,
36
+ backHref,
37
+ mode
38
+ }: {
39
+ ticketId: string
40
+ backHref: string
41
+ /** `admin` answers as the support team and can change the status. */
42
+ mode: 'app' | 'admin'
43
+ }) {
44
+ const [message, setMessage] = useState('')
45
+ const { data, isLoading, refetch } = useSafeActionQuery(
46
+ `support-ticket-${ticketId}`,
47
+ getSupportTicketDetailsAction,
48
+ { ticketId }
49
+ )
50
+
51
+ const addMessage = useSafeActionMutation(addSupportTicketMessageAction, {
52
+ onSuccess: () => {
53
+ setMessage('')
54
+ void refetch()
55
+ toastSuccessMessage('Message sent')
56
+ },
57
+ onError: error =>
58
+ toastActionError(error, 'Your message was not sent. Try again.')
59
+ })
60
+
61
+ const updateStatus = useSafeActionMutation(updateSupportTicketStatusAction, {
62
+ onSuccess: () => {
63
+ void refetch()
64
+ toastSuccessMessage('Ticket status updated')
65
+ },
66
+ onError: error =>
67
+ toastActionError(error, 'The status was not changed. Try again.')
68
+ })
69
+
70
+ if (isLoading) {
71
+ return (
72
+ <div className="flex h-[60vh] items-center justify-center">
73
+ <Loader2 className="text-primary h-8 w-8 animate-spin" />
74
+ </div>
75
+ )
76
+ }
77
+
78
+ if (!data) return null
79
+
80
+ const customerReplyDisabled =
81
+ mode === 'app' && ['closed', 'resolved'].includes(data.ticket.status)
82
+ const replyDisabled =
83
+ customerReplyDisabled || addMessage.isPending || !message.trim()
84
+
85
+ return (
86
+ <div className="space-y-5">
87
+ <div className="flex flex-wrap items-start justify-between gap-3">
88
+ <div className="space-y-3">
89
+ <Button asChild variant="ghost" size="sm" className="-ml-2">
90
+ <Link href={backHref}>
91
+ <ArrowLeft className="mr-2 h-4 w-4" />
92
+ Back
93
+ </Link>
94
+ </Button>
95
+ <div>
96
+ <div className="text-muted-foreground font-mono text-sm">
97
+ {data.ticket.ticketNumber}
98
+ </div>
99
+ <h1 className="text-2xl font-bold tracking-tight">
100
+ {data.ticket.subject}
101
+ </h1>
102
+ <div className="mt-2 flex flex-wrap items-center gap-2">
103
+ <SupportStatusBadge status={data.ticket.status} />
104
+ <SupportPriorityBadge priority={data.ticket.priority} />
105
+ <span className="text-muted-foreground text-sm">
106
+ Created {formatDate(data.ticket.createdAt, { short: true })}
107
+ </span>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ {mode === 'admin' ? (
113
+ <div className="min-w-52">
114
+ <Select
115
+ value={data.ticket.status}
116
+ onValueChange={value =>
117
+ updateStatus.mutate({
118
+ ticketId,
119
+ status: value as SupportTicketStatus
120
+ })
121
+ }
122
+ disabled={updateStatus.isPending}
123
+ >
124
+ <SelectTrigger className="w-full">
125
+ <SelectValue placeholder="Update status" />
126
+ </SelectTrigger>
127
+ <SelectContent>
128
+ {supportStatusOptions.map(option => (
129
+ <SelectItem key={option.value} value={option.value}>
130
+ {option.label}
131
+ </SelectItem>
132
+ ))}
133
+ </SelectContent>
134
+ </Select>
135
+ </div>
136
+ ) : null}
137
+ </div>
138
+
139
+ <div className="grid gap-5 lg:grid-cols-[1fr_280px]">
140
+ <section className="border-border bg-card overflow-hidden rounded-lg border shadow-sm">
141
+ <div className="border-border bg-muted/30 border-b px-5 py-3">
142
+ <h2 className="font-semibold">Conversation</h2>
143
+ </div>
144
+ <div className="space-y-4 p-5">
145
+ {data.messages.map(item => {
146
+ const mine = mode === 'admin' ? item.isAdmin : !item.isAdmin
147
+ return (
148
+ <div
149
+ key={item.id}
150
+ className={`flex ${mine ? 'justify-end' : 'justify-start'}`}
151
+ >
152
+ <div
153
+ className={`max-w-[82%] rounded-lg border px-4 py-3 ${
154
+ mine
155
+ ? 'bg-primary text-primary-foreground border-primary'
156
+ : 'bg-background'
157
+ }`}
158
+ >
159
+ <div className="mb-1 text-xs opacity-75">
160
+ {item.isAdmin
161
+ ? 'Support Team'
162
+ : item.userName || item.userEmail || 'Customer'}{' '}
163
+ ·{' '}
164
+ {formatDate(item.createdAt, { short: true, time: true })}
165
+ </div>
166
+ <p className="text-sm leading-relaxed whitespace-pre-wrap">
167
+ {item.message}
168
+ </p>
169
+ </div>
170
+ </div>
171
+ )
172
+ })}
173
+ </div>
174
+ <div className="border-border bg-muted/20 space-y-3 border-t p-5">
175
+ <Textarea
176
+ value={message}
177
+ disabled={customerReplyDisabled || addMessage.isPending}
178
+ onChange={event => setMessage(event.target.value)}
179
+ placeholder={
180
+ customerReplyDisabled
181
+ ? 'This ticket is resolved or closed. Open a new one to follow up.'
182
+ : 'Write a reply…'
183
+ }
184
+ className="min-h-28"
185
+ />
186
+ <div className="flex justify-end">
187
+ <Button
188
+ disabled={replyDisabled}
189
+ onClick={() =>
190
+ addMessage.mutate({
191
+ ticketId,
192
+ message,
193
+ asSupport: mode === 'admin'
194
+ })
195
+ }
196
+ >
197
+ <Send className="mr-2 h-4 w-4" />
198
+ Send message
199
+ </Button>
200
+ </div>
201
+ </div>
202
+ </section>
203
+
204
+ <aside className="border-border bg-card h-fit rounded-lg border p-5 shadow-sm">
205
+ <h2 className="font-semibold">Ticket Details</h2>
206
+ <dl className="mt-4 space-y-3 text-sm">
207
+ <div>
208
+ <dt className="text-muted-foreground">Status</dt>
209
+ <dd>{formatSupportLabel(data.ticket.status)}</dd>
210
+ </div>
211
+ <div>
212
+ <dt className="text-muted-foreground">Priority</dt>
213
+ <dd>{formatSupportLabel(data.ticket.priority)}</dd>
214
+ </div>
215
+ <div>
216
+ <dt className="text-muted-foreground">Organization</dt>
217
+ <dd>{data.ticket.organizationName || '-'}</dd>
218
+ </div>
219
+ <div>
220
+ <dt className="text-muted-foreground">Raised by</dt>
221
+ <dd>{data.ticket.userName || data.ticket.userEmail || '-'}</dd>
222
+ </div>
223
+ <div>
224
+ <dt className="text-muted-foreground">Resolved</dt>
225
+ <dd>
226
+ {data.ticket.resolvedAt
227
+ ? formatDate(data.ticket.resolvedAt, { short: true })
228
+ : '-'}
229
+ </dd>
230
+ </div>
231
+ </dl>
232
+ </aside>
233
+ </div>
234
+ </div>
235
+ )
236
+ }
@@ -0,0 +1,58 @@
1
+ import { Badge } from '~/components/ui/badge'
2
+
3
+ export const supportStatusOptions = [
4
+ { label: 'Open', value: 'open' },
5
+ { label: 'In Progress', value: 'in_progress' },
6
+ { label: 'Waiting on Customer', value: 'waiting_customer' },
7
+ { label: 'Resolved', value: 'resolved' },
8
+ { label: 'Closed', value: 'closed' }
9
+ ] as const
10
+
11
+ export const supportPriorityOptions = [
12
+ { label: 'Low', value: 'low' },
13
+ { label: 'Medium', value: 'medium' },
14
+ { label: 'High', value: 'high' },
15
+ { label: 'Urgent', value: 'urgent' }
16
+ ] as const
17
+
18
+ export type SupportTicketStatus = (typeof supportStatusOptions)[number]['value']
19
+ export type SupportTicketPriority =
20
+ (typeof supportPriorityOptions)[number]['value']
21
+
22
+ export function formatSupportLabel(value: string | null) {
23
+ if (!value) return '-'
24
+ return value.replaceAll('_', ' ').replace(/\b\w/g, char => char.toUpperCase())
25
+ }
26
+
27
+ export function SupportStatusBadge({ status }: { status: string }) {
28
+ const variantMap = {
29
+ open: 'info',
30
+ in_progress: 'info',
31
+ waiting_customer: 'warning',
32
+ resolved: 'success',
33
+ closed: 'secondary'
34
+ } as const
35
+
36
+ const variant = variantMap[status as keyof typeof variantMap] ?? 'secondary'
37
+
38
+ return <Badge variant={variant}>{formatSupportLabel(status)}</Badge>
39
+ }
40
+
41
+ export function SupportPriorityBadge({
42
+ priority
43
+ }: {
44
+ priority: string | null
45
+ }) {
46
+ if (!priority) return <span className="text-muted-foreground">-</span>
47
+
48
+ const variantMap = {
49
+ low: 'outline',
50
+ medium: 'secondary',
51
+ high: 'warning',
52
+ urgent: 'destructive'
53
+ } as const
54
+
55
+ const variant = variantMap[priority as keyof typeof variantMap] ?? 'secondary'
56
+
57
+ return <Badge variant={variant}>{formatSupportLabel(priority)}</Badge>
58
+ }
@@ -0,0 +1,281 @@
1
+ 'use client'
2
+
3
+ import { Eye, Plus, Ticket } from 'lucide-react'
4
+ import Link from 'next/link'
5
+ import {
6
+ parseAsInteger,
7
+ parseAsJson,
8
+ parseAsString,
9
+ useQueryStates
10
+ } from 'nuqs'
11
+ import {
12
+ getAdminSupportTicketsAction,
13
+ getMyAccountSupportTicketsAction,
14
+ getOrganizationSupportTicketsAction,
15
+ getSupportTicketOrganizationsAction
16
+ } from '~/app/actions/support-tickets'
17
+ import { DataTable, type DataTableColumnDef } from '~/components/data-table'
18
+ import { SortOrderEnum } from '~/components/data-table/enum'
19
+ import { DataTableFilter } from '~/components/data-table/types'
20
+ import { PageHeading } from '~/components/page-heading'
21
+ import { Button } from '~/components/ui/button'
22
+ import { formatDate } from '~/lib/format-date'
23
+ import { useSafeActionQuery } from '~/lib/safe-action-client'
24
+ import { CreateSupportTicketDialog } from './create-support-ticket-dialog'
25
+ import {
26
+ SupportPriorityBadge,
27
+ SupportStatusBadge,
28
+ supportPriorityOptions,
29
+ supportStatusOptions
30
+ } from './support-ticket-shared'
31
+
32
+ type SupportTicket = {
33
+ id: string
34
+ ticketNumber: string
35
+ subject: string
36
+ status: string
37
+ priority: string | null
38
+ createdAt: Date
39
+ resolvedAt: Date | null
40
+ userId: string | null
41
+ userName: string | null
42
+ userEmail: string | null
43
+ organizationId: string | null
44
+ organizationName: string | null
45
+ }
46
+
47
+ const baseFilters: DataTableFilter[] = [
48
+ {
49
+ id: 'status',
50
+ label: 'Status',
51
+ options: supportStatusOptions.map(option => ({
52
+ label: option.label,
53
+ value: option.value
54
+ }))
55
+ },
56
+ {
57
+ id: 'priority',
58
+ label: 'Priority',
59
+ options: supportPriorityOptions.map(option => ({
60
+ label: option.label,
61
+ value: option.value
62
+ }))
63
+ }
64
+ ]
65
+
66
+ // Only the platform view spans organizations, so only it filters by one.
67
+ const adminFilters: DataTableFilter[] = [
68
+ ...baseFilters,
69
+ {
70
+ id: 'organizationId',
71
+ label: 'Organization',
72
+ action: getSupportTicketOrganizationsAction,
73
+ queryKey: 'support-ticket-organizations'
74
+ }
75
+ ]
76
+
77
+ type SupportTicketsTableProps =
78
+ | { mode: 'admin' | 'account'; basePath: string; organizationId?: undefined }
79
+ | { mode: 'app'; basePath: string; organizationId: string }
80
+
81
+ const COPY = {
82
+ admin: {
83
+ title: 'Support Tickets',
84
+ description: 'View, respond to, and manage customer support conversations.',
85
+ searchPlaceholder: 'Search by ticket #, subject, user, or organization…'
86
+ },
87
+ app: {
88
+ title: 'Support Tickets',
89
+ description:
90
+ 'Every ticket for this organization. Owners and admins can reply to all of them.',
91
+ searchPlaceholder: 'Search by ticket # or subject…'
92
+ },
93
+ account: {
94
+ title: 'Support',
95
+ description:
96
+ "Tickets about your account. To raise one about an organization, use that organization's support page.",
97
+ searchPlaceholder: 'Search by ticket # or subject…'
98
+ }
99
+ } as const
100
+
101
+ export function SupportTicketsTable({
102
+ mode,
103
+ basePath,
104
+ organizationId
105
+ }: SupportTicketsTableProps) {
106
+ const [queryState, setQueryState] = useQueryStates({
107
+ page: parseAsInteger.withDefault(1),
108
+ limit: parseAsInteger.withDefault(10),
109
+ sortBy: parseAsString,
110
+ sortOrder: parseAsString,
111
+ search: parseAsString,
112
+ filters: parseAsJson(v => v as Record<string, (string | boolean)[]>)
113
+ })
114
+
115
+ const params = {
116
+ page: queryState.page,
117
+ limit: queryState.limit,
118
+ sortBy: (queryState.sortBy ?? undefined) as
119
+ | 'ticketNumber'
120
+ | 'subject'
121
+ | 'status'
122
+ | 'priority'
123
+ | 'createdAt'
124
+ | 'resolvedAt'
125
+ | undefined,
126
+ sortOrder: (queryState.sortOrder ?? undefined) as SortOrderEnum | undefined,
127
+ search: queryState.search ?? undefined,
128
+ filters: queryState.filters ?? undefined
129
+ }
130
+
131
+ const adminQuery = useSafeActionQuery(
132
+ 'admin-support-tickets',
133
+ getAdminSupportTicketsAction,
134
+ params,
135
+ { enabled: mode === 'admin' }
136
+ )
137
+ const organizationQuery = useSafeActionQuery(
138
+ `support-tickets-${organizationId}`,
139
+ getOrganizationSupportTicketsAction,
140
+ { ...params, organizationId: organizationId! },
141
+ { enabled: mode === 'app' }
142
+ )
143
+ const accountQuery = useSafeActionQuery(
144
+ 'account-support-tickets',
145
+ getMyAccountSupportTicketsAction,
146
+ params,
147
+ { enabled: mode === 'account' }
148
+ )
149
+ const { data, isLoading } =
150
+ mode === 'admin'
151
+ ? adminQuery
152
+ : mode === 'app'
153
+ ? organizationQuery
154
+ : accountQuery
155
+
156
+ const columns: DataTableColumnDef<SupportTicket>[] = [
157
+ {
158
+ accessorKey: 'ticketNumber',
159
+ header: 'Ticket #',
160
+ enableSorting: true,
161
+ cell: ({ row }) => (
162
+ <span className="font-mono text-sm">{row.original.ticketNumber}</span>
163
+ )
164
+ },
165
+ {
166
+ accessorKey: 'subject',
167
+ header: mode === 'account' ? 'Subject' : 'Subject & Raised by',
168
+ enableSorting: true,
169
+ cell: ({ row }) => (
170
+ <div className="flex flex-col">
171
+ <span className="line-clamp-1 text-sm font-medium">
172
+ {row.original.subject}
173
+ </span>
174
+ {mode === 'account' ? null : (
175
+ <span className="text-muted-foreground text-xs">
176
+ {row.original.userName ||
177
+ row.original.userEmail ||
178
+ 'Removed user'}
179
+ </span>
180
+ )}
181
+ </div>
182
+ )
183
+ },
184
+ ...(mode === 'admin'
185
+ ? [
186
+ {
187
+ accessorKey: 'organizationName',
188
+ header: 'Organization',
189
+ enableSorting: false,
190
+ cell: ({ row }: { row: { original: SupportTicket } }) =>
191
+ row.original.organizationId ? (
192
+ <span className="line-clamp-1 text-sm">
193
+ {row.original.organizationName || 'Unknown'}
194
+ </span>
195
+ ) : (
196
+ <span className="text-muted-foreground text-sm">Account</span>
197
+ )
198
+ } satisfies DataTableColumnDef<SupportTicket>
199
+ ]
200
+ : []),
201
+ {
202
+ accessorKey: 'status',
203
+ header: 'Status',
204
+ enableSorting: true,
205
+ cell: ({ row }) => <SupportStatusBadge status={row.original.status} />
206
+ },
207
+ {
208
+ accessorKey: 'priority',
209
+ header: 'Priority',
210
+ enableSorting: true,
211
+ cell: ({ row }) => (
212
+ <SupportPriorityBadge priority={row.original.priority} />
213
+ )
214
+ },
215
+ {
216
+ accessorKey: 'createdAt',
217
+ header: 'Created',
218
+ enableSorting: true,
219
+ cell: ({ row }) => formatDate(row.original.createdAt, { short: true })
220
+ },
221
+ {
222
+ id: 'actions',
223
+ cell: ({ row }) => (
224
+ <Button asChild variant="ghost" size="icon" className="h-8 w-8">
225
+ <Link href={`${basePath}/${row.original.id}`}>
226
+ <Eye className="h-4 w-4" />
227
+ <span className="sr-only">View ticket</span>
228
+ </Link>
229
+ </Button>
230
+ )
231
+ }
232
+ ]
233
+
234
+ return (
235
+ <div className="space-y-6">
236
+ <PageHeading
237
+ title={COPY[mode].title}
238
+ description={COPY[mode].description}
239
+ icon={Ticket}
240
+ >
241
+ {mode === 'admin' ? null : (
242
+ <CreateSupportTicketDialog
243
+ organizationId={organizationId}
244
+ redirectBasePath={basePath}
245
+ >
246
+ <Button size="sm">
247
+ <Plus className="mr-2 h-4 w-4" />
248
+ New Ticket
249
+ </Button>
250
+ </CreateSupportTicketDialog>
251
+ )}
252
+ </PageHeading>
253
+
254
+ <DataTable
255
+ columns={columns}
256
+ data={data?.[0]}
257
+ isLoading={isLoading}
258
+ totalCount={data?.[1]}
259
+ enableSearch
260
+ initialSearch={params.search}
261
+ searchPlaceholder={COPY[mode].searchPlaceholder}
262
+ manualPagination
263
+ manualSorting
264
+ pageIndex={params.page - 1}
265
+ pageSize={params.limit}
266
+ filters={mode === 'admin' ? adminFilters : baseFilters}
267
+ activeFilters={params.filters ?? {}}
268
+ onParamsChange={({ page, limit, sortBy, sortOrder, search, filters }) =>
269
+ setQueryState({
270
+ page,
271
+ limit,
272
+ sortBy: sortBy ?? null,
273
+ sortOrder: sortOrder ?? null,
274
+ search: search ?? null,
275
+ filters: filters ?? null
276
+ })
277
+ }
278
+ />
279
+ </div>
280
+ )
281
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @fileoverview Theme provider for light/dark mode and theme hotkeys.
3
+ *
4
+ * Wraps the application with Next Themes provider and manages theme switching.
5
+ * Includes a hotkey component (D key) to toggle the theme.
6
+ *
7
+ * **Features:**
8
+ * - System-aware default theme preference
9
+ * - Light/dark/system theme options
10
+ * - Keyboard shortcut (D key) for theme toggle
11
+ * - No theme transitions on page load
12
+ *
13
+ * @module components/theme-provider
14
+ */
15
+
16
+ 'use client'
17
+
18
+ import { ThemeProvider as NextThemesProvider, useTheme } from 'next-themes'
19
+ import { FC, useEffect } from 'react'
20
+
21
+ /**
22
+ * Determines if the target element is a text input field.
23
+ * Used to avoid triggering theme hotkey when typing in inputs.
24
+ *
25
+ * @param target - DOM event target to check
26
+ * @returns true if target is a contenteditable, input, textarea, or select element
27
+ */
28
+ function isTypingTarget(target: EventTarget | null) {
29
+ if (!(target instanceof HTMLElement)) {
30
+ return false
31
+ }
32
+
33
+ return (
34
+ target.isContentEditable ||
35
+ target.tagName === 'INPUT' ||
36
+ target.tagName === 'TEXTAREA' ||
37
+ target.tagName === 'SELECT'
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Theme Hotkey Component
43
+ *
44
+ * Listens for the 'D' key and toggles between light and dark themes.
45
+ * Ignores hotkey when user is typing in an input field.
46
+ *
47
+ * **Behavior:**
48
+ * - Press 'D' anywhere to toggle light ↔ dark
49
+ * - No effect if typing in input, textarea, or contenteditable element
50
+ * - Requires modifier keys (Ctrl, Cmd, Alt) to NOT be pressed
51
+ *
52
+ * @returns {null} Does not render any elements
53
+ */
54
+ function ThemeHotkey() {
55
+ const { resolvedTheme, setTheme } = useTheme()
56
+
57
+ useEffect(() => {
58
+ /**
59
+ * Handles keyboard down events for theme toggle.
60
+ * Checks conditions and prevents default if hotkey is triggered.
61
+ */
62
+ function onKeyDown(event: KeyboardEvent) {
63
+ if (event.defaultPrevented || event.repeat) {
64
+ return
65
+ }
66
+
67
+ if (event.metaKey || event.ctrlKey || event.altKey) {
68
+ return
69
+ }
70
+
71
+ if (event.key.toLowerCase() !== 'd') {
72
+ return
73
+ }
74
+
75
+ if (isTypingTarget(event.target)) {
76
+ return
77
+ }
78
+
79
+ setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
80
+ }
81
+
82
+ window.addEventListener('keydown', onKeyDown)
83
+
84
+ return () => {
85
+ window.removeEventListener('keydown', onKeyDown)
86
+ }
87
+ }, [resolvedTheme, setTheme])
88
+
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * Theme Provider Component
94
+ *
95
+ * Wraps application with Next Themes provider and mounts theme hotkey.
96
+ *
97
+ * @param {Object} props - Component props
98
+ * @param {React.ReactNode} props.children - Child components to wrap
99
+ * @returns {JSX.Element} Provider wrapping children with theme support
100
+ */
101
+ export const ThemeProvider: FC<{
102
+ children: React.ReactNode
103
+ }> = ({ children }) => {
104
+ return (
105
+ <NextThemesProvider
106
+ attribute="class"
107
+ defaultTheme="system"
108
+ enableSystem
109
+ disableTransitionOnChange
110
+ >
111
+ <ThemeHotkey />
112
+ {children}
113
+ </NextThemesProvider>
114
+ )
115
+ }