create-ej-app 26.0.0 → 27.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +79 -29
  3. package/package.json +2 -2
  4. package/template/api/.vscode/settings.json +1 -1
  5. package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
  6. package/template/api/CLAUDE.md +1 -0
  7. package/template/api/package.json +10 -10
  8. package/template/nextjs/.dockerignore +1 -0
  9. package/template/nextjs/AGENTS.md +260 -0
  10. package/template/nextjs/CLAUDE.md +1 -0
  11. package/template/nextjs/README.md +109 -0
  12. package/template/nextjs/bunfig.toml +3 -0
  13. package/template/nextjs/eslint.config.mjs +6 -5
  14. package/template/nextjs/gitignore +6 -1
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +46 -39
  17. package/template/nextjs/public/logo-dark.svg +4 -0
  18. package/template/nextjs/public/logo.svg +4 -0
  19. package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
  20. package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  21. package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  22. package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  23. package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
  24. package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
  25. package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
  26. package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  27. package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  28. package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  29. package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  30. package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  31. package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
  32. package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
  33. package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
  34. package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
  35. package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  36. package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
  37. package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
  38. package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
  39. package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
  40. package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
  41. package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
  42. package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
  43. package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
  44. package/template/nextjs/src/app/error.tsx +37 -0
  45. package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  46. package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  47. package/template/nextjs/src/app/fonts/index.ts +60 -0
  48. package/template/nextjs/src/app/global-error.tsx +38 -0
  49. package/template/nextjs/src/app/globals.css +29 -0
  50. package/template/nextjs/src/app/layout.tsx +45 -18
  51. package/template/nextjs/src/app/not-found.tsx +25 -3
  52. package/template/nextjs/src/app/robots.ts +29 -0
  53. package/template/nextjs/src/app/sitemap.ts +21 -0
  54. package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
  55. package/template/nextjs/src/components/dashboard-header.tsx +70 -0
  56. package/template/nextjs/src/components/data-table/enum.ts +5 -0
  57. package/template/nextjs/src/components/data-table/features.ts +49 -0
  58. package/template/nextjs/src/components/data-table/index.tsx +961 -0
  59. package/template/nextjs/src/components/data-table/types.ts +76 -0
  60. package/template/nextjs/src/components/error.tsx +71 -0
  61. package/template/nextjs/src/components/loader.tsx +18 -2
  62. package/template/nextjs/src/components/logo.tsx +49 -0
  63. package/template/nextjs/src/components/navigation-search.tsx +140 -0
  64. package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
  65. package/template/nextjs/src/components/page-heading.tsx +38 -0
  66. package/template/nextjs/src/components/react-query-provider.tsx +41 -2
  67. package/template/nextjs/src/components/route-error.tsx +55 -0
  68. package/template/nextjs/src/components/screen-size.tsx +36 -0
  69. package/template/nextjs/src/components/site-footer.tsx +20 -4
  70. package/template/nextjs/src/components/theme-provider.tsx +48 -0
  71. package/template/nextjs/src/components/theme-toggle.tsx +58 -5
  72. package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
  73. package/template/nextjs/src/components/ui/avatar.tsx +112 -0
  74. package/template/nextjs/src/components/ui/badge.tsx +54 -0
  75. package/template/nextjs/src/components/ui/button.tsx +3 -1
  76. package/template/nextjs/src/components/ui/card.tsx +4 -4
  77. package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
  78. package/template/nextjs/src/components/ui/command.tsx +192 -0
  79. package/template/nextjs/src/components/ui/dialog.tsx +164 -0
  80. package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
  81. package/template/nextjs/src/components/ui/input-group.tsx +156 -0
  82. package/template/nextjs/src/components/ui/select.tsx +195 -0
  83. package/template/nextjs/src/components/ui/sheet.tsx +143 -0
  84. package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
  85. package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
  86. package/template/nextjs/src/components/ui/table.tsx +116 -0
  87. package/template/nextjs/src/components/ui/textarea.tsx +18 -0
  88. package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
  89. package/template/nextjs/src/components/user-menu.tsx +206 -0
  90. package/template/nextjs/src/db/common.ts +10 -0
  91. package/template/nextjs/src/db/index.ts +16 -6
  92. package/template/nextjs/src/db/relations.ts +21 -0
  93. package/template/nextjs/src/db/schema.ts +85 -46
  94. package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
  95. package/template/nextjs/src/emails/magic-link.tsx +15 -13
  96. package/template/nextjs/src/emails/test.tsx +11 -9
  97. package/template/nextjs/src/emails/welcome.tsx +12 -9
  98. package/template/nextjs/src/env.ts +1 -1
  99. package/template/nextjs/src/hooks/use-mobile.ts +14 -12
  100. package/template/nextjs/src/instrumentation.ts +20 -2
  101. package/template/nextjs/src/lib/actions.ts +55 -0
  102. package/template/nextjs/src/lib/admin-navigation.ts +43 -0
  103. package/template/nextjs/src/lib/auth-client.ts +20 -9
  104. package/template/nextjs/src/lib/auth-permissions.ts +61 -0
  105. package/template/nextjs/src/lib/auth.ts +33 -3
  106. package/template/nextjs/src/lib/callback-url.ts +28 -0
  107. package/template/nextjs/src/lib/constants.ts +21 -0
  108. package/template/nextjs/src/lib/delay.ts +10 -0
  109. package/template/nextjs/src/lib/email-service.ts +21 -0
  110. package/template/nextjs/src/lib/errors.ts +74 -0
  111. package/template/nextjs/src/lib/format-currency.ts +22 -24
  112. package/template/nextjs/src/lib/format-date.ts +69 -6
  113. package/template/nextjs/src/lib/nodemailer.ts +27 -3
  114. package/template/nextjs/src/lib/otel.ts +92 -2
  115. package/template/nextjs/src/lib/safe-action-client.ts +60 -9
  116. package/template/nextjs/src/lib/safe-action.ts +152 -16
  117. package/template/nextjs/src/lib/search.ts +16 -0
  118. package/template/nextjs/src/lib/seo.ts +7 -0
  119. package/template/nextjs/src/lib/siteConfig.ts +17 -1
  120. package/template/nextjs/src/lib/toast-message.ts +87 -1
  121. package/template/nextjs/src/lib/validations.ts +30 -0
  122. package/template/nextjs/src/proxy.ts +48 -0
  123. package/template/nextjs/src/scripts/reset-db.ts +22 -0
  124. package/template/nextjs/src/scripts/seed.ts +59 -8
  125. package/template/nextjs/src/scripts/test-mail.ts +28 -20
  126. package/template/nextjs/tests/admin/users.test.ts +64 -0
  127. package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
  128. package/template/nextjs/tests/db/seed.test.ts +49 -0
  129. package/template/nextjs/tests/helpers/action.ts +71 -0
  130. package/template/nextjs/tests/helpers/fixtures.ts +78 -0
  131. package/template/nextjs/tests/helpers/index.ts +2 -0
  132. package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
  133. package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
  134. package/template/nextjs/tests/lib/errors.test.ts +10 -0
  135. package/template/nextjs/tests/lib/search.test.ts +23 -0
  136. package/template/nextjs/tests/setup/pglite-db.ts +63 -0
  137. package/template/nextjs/tests/setup/preload.ts +95 -0
  138. package/template/nextjs/tsconfig.json +1 -0
  139. package/template/saas-starter/.dockerignore +1 -0
  140. package/template/saas-starter/.env.example +15 -15
  141. package/template/saas-starter/AGENTS.md +302 -0
  142. package/template/saas-starter/CLAUDE.md +1 -0
  143. package/template/saas-starter/README.md +124 -2
  144. package/template/saas-starter/bunfig.toml +3 -0
  145. package/template/saas-starter/eslint.config.mjs +6 -5
  146. package/template/saas-starter/gitignore +6 -1
  147. package/template/saas-starter/next.config.ts +19 -1
  148. package/template/saas-starter/package.json +49 -44
  149. package/template/saas-starter/public/logo-dark.svg +4 -0
  150. package/template/saas-starter/public/logo.svg +4 -0
  151. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  152. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  153. package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
  154. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
  155. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
  156. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  157. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  158. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
  159. package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
  160. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  161. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  162. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  163. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
  164. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  165. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  166. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  167. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  168. package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
  169. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  170. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  171. package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
  172. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  173. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  174. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
  175. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
  176. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
  177. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
  178. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
  179. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
  180. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
  181. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  182. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  188. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
  189. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
  190. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
  191. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  192. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
  193. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
  194. package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
  195. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
  196. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
  197. package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
  198. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  199. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  200. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  201. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  202. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  203. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
  204. package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
  205. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
  206. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
  207. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
  208. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  209. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  210. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  211. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  212. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  213. package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
  214. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  215. package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
  216. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
  217. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
  218. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  219. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  220. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  221. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  222. package/template/saas-starter/src/app/error.tsx +37 -0
  223. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  224. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  225. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  226. package/template/saas-starter/src/app/global-error.tsx +38 -0
  227. package/template/saas-starter/src/app/globals.css +29 -0
  228. package/template/saas-starter/src/app/layout.tsx +43 -19
  229. package/template/saas-starter/src/app/not-found.tsx +25 -3
  230. package/template/saas-starter/src/app/robots.ts +14 -10
  231. package/template/saas-starter/src/app/sitemap.ts +2 -1
  232. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  233. package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
  234. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  235. package/template/saas-starter/src/components/data-table/index.tsx +65 -64
  236. package/template/saas-starter/src/components/data-table/types.ts +5 -4
  237. package/template/saas-starter/src/components/error.tsx +45 -0
  238. package/template/saas-starter/src/components/file-upload.tsx +65 -26
  239. package/template/saas-starter/src/components/loader.tsx +18 -2
  240. package/template/saas-starter/src/components/logo.tsx +49 -0
  241. package/template/saas-starter/src/components/navigation-search.tsx +2 -2
  242. package/template/saas-starter/src/components/page-heading.tsx +6 -0
  243. package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
  244. package/template/saas-starter/src/components/route-error.tsx +55 -0
  245. package/template/saas-starter/src/components/screen-size.tsx +36 -0
  246. package/template/saas-starter/src/components/site-footer.tsx +20 -4
  247. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  248. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  249. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  250. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  251. package/template/saas-starter/src/components/theme-provider.tsx +48 -0
  252. package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
  253. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  254. package/template/saas-starter/src/components/ui/badge.tsx +6 -1
  255. package/template/saas-starter/src/components/ui/button.tsx +2 -0
  256. package/template/saas-starter/src/components/ui/command.tsx +1 -1
  257. package/template/saas-starter/src/components/user-menu.tsx +147 -24
  258. package/template/saas-starter/src/db/common.ts +33 -1
  259. package/template/saas-starter/src/db/enums.ts +24 -0
  260. package/template/saas-starter/src/db/index.ts +14 -5
  261. package/template/saas-starter/src/db/relations.ts +40 -3
  262. package/template/saas-starter/src/db/schema.ts +186 -35
  263. package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
  264. package/template/saas-starter/src/emails/invitation.tsx +34 -21
  265. package/template/saas-starter/src/emails/magic-link.tsx +15 -13
  266. package/template/saas-starter/src/emails/test.tsx +11 -9
  267. package/template/saas-starter/src/emails/welcome.tsx +12 -9
  268. package/template/saas-starter/src/env.ts +1 -1
  269. package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
  270. package/template/saas-starter/src/instrumentation.ts +20 -2
  271. package/template/saas-starter/src/lib/actions.ts +34 -0
  272. package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
  273. package/template/saas-starter/src/lib/app-navigation.ts +45 -41
  274. package/template/saas-starter/src/lib/auth-client.ts +8 -3
  275. package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
  276. package/template/saas-starter/src/lib/auth.ts +122 -21
  277. package/template/saas-starter/src/lib/billing.ts +59 -24
  278. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  279. package/template/saas-starter/src/lib/constants.ts +78 -9
  280. package/template/saas-starter/src/lib/delay.ts +10 -0
  281. package/template/saas-starter/src/lib/email-service.ts +30 -1
  282. package/template/saas-starter/src/lib/errors.ts +74 -0
  283. package/template/saas-starter/src/lib/format-currency.ts +22 -24
  284. package/template/saas-starter/src/lib/format-date.ts +69 -6
  285. package/template/saas-starter/src/lib/image-url.ts +54 -0
  286. package/template/saas-starter/src/lib/nodemailer.ts +27 -3
  287. package/template/saas-starter/src/lib/organization-access.ts +56 -76
  288. package/template/saas-starter/src/lib/otel.ts +92 -2
  289. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  290. package/template/saas-starter/src/lib/plans.ts +40 -13
  291. package/template/saas-starter/src/lib/rbac.ts +44 -0
  292. package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
  293. package/template/saas-starter/src/lib/safe-action.ts +137 -17
  294. package/template/saas-starter/src/lib/search.ts +16 -0
  295. package/template/saas-starter/src/lib/seo.ts +7 -0
  296. package/template/saas-starter/src/lib/siteConfig.ts +17 -1
  297. package/template/saas-starter/src/lib/storage.ts +176 -46
  298. package/template/saas-starter/src/lib/stripe.ts +4 -1
  299. package/template/saas-starter/src/lib/toast-message.ts +87 -1
  300. package/template/saas-starter/src/lib/validations.ts +85 -2
  301. package/template/saas-starter/src/proxy.ts +48 -0
  302. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  303. package/template/saas-starter/src/scripts/seed.ts +82 -8
  304. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  305. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
  306. package/template/saas-starter/src/scripts/test-mail.ts +28 -20
  307. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  308. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  309. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  310. package/template/saas-starter/tests/helpers/action.ts +71 -0
  311. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  312. package/template/saas-starter/tests/helpers/index.ts +2 -0
  313. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  314. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  315. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  316. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  317. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  318. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  319. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  320. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  321. package/template/saas-starter/tests/setup/preload.ts +108 -0
  322. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  323. package/template/saas-starter/tsconfig.json +1 -0
  324. package/cli.js +0 -147
  325. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  326. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  327. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  328. package/template/nextjs/terraform/backend-service.tf +0 -13
  329. package/template/nextjs/terraform/cleanup.sh +0 -9
  330. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  331. package/template/nextjs/terraform/cloudrun.tf +0 -68
  332. package/template/nextjs/terraform/database.tf +0 -60
  333. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  334. package/template/nextjs/terraform/gitignore +0 -4
  335. package/template/nextjs/terraform/ip-address.tf +0 -11
  336. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  337. package/template/nextjs/terraform/network.tf +0 -21
  338. package/template/nextjs/terraform/pre.sh +0 -3
  339. package/template/nextjs/terraform/providers.tf +0 -18
  340. package/template/nextjs/terraform/secret.txt +0 -11
  341. package/template/nextjs/terraform/setup.sh +0 -25
  342. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  343. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  344. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  345. package/template/nextjs/terraform/terraform.tfvars +0 -4
  346. package/template/nextjs/terraform/url-map.tf +0 -23
  347. package/template/nextjs/terraform/variables.tf +0 -21
  348. package/template/saas-starter/next-env.d.ts +0 -6
  349. package/template/saas-starter/src/actions/uploads.ts +0 -28
  350. package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
  351. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
  352. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
  353. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
  354. package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
  355. package/template/saas-starter/src/app/proxy.ts +0 -28
  356. package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
  357. package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @fileoverview Generic, feature-rich DataTable component built on TanStack Table.
2
+ * @fileoverview Generic DataTable built on TanStack Table.
3
3
  *
4
4
  * Supports:
5
5
  * - Server-side (manual) or client-side pagination & sorting
@@ -14,13 +14,10 @@
14
14
  'use client'
15
15
 
16
16
  import {
17
- SortingState,
18
- VisibilityState,
19
- flexRender,
20
- getCoreRowModel,
21
- getPaginationRowModel,
22
- getSortedRowModel,
23
- useReactTable
17
+ type ColumnVisibilityState,
18
+ type RowData,
19
+ type SortingState,
20
+ useTable
24
21
  } from '@tanstack/react-table'
25
22
  import {
26
23
  ArrowDown,
@@ -93,6 +90,7 @@ import { cn } from '~/lib/cn'
93
90
  import { useSafeActionQuery } from '~/lib/safe-action-client'
94
91
  import { Skeleton } from '../ui/skeleton'
95
92
  import { SortOrderEnum } from './enum'
93
+ import { dataTableFeatures } from './features'
96
94
  import { DataTableFilter, DataTableProps } from './types'
97
95
 
98
96
  /**
@@ -161,7 +159,7 @@ const StaticFilterSubContent = memo(function StaticFilterSubContent({
161
159
  {isTruncated ? (
162
160
  <Tooltip>
163
161
  <TooltipTrigger asChild>
164
- <span>{option.label.slice(0, 18) + '...'}</span>
162
+ <span>{option.label.slice(0, 18) + '…'}</span>
165
163
  </TooltipTrigger>
166
164
  <TooltipContent side="right">
167
165
  <p className="max-w-75 break-all">{option.label}</p>
@@ -284,7 +282,7 @@ const AsyncFilterSubContent = memo(function AsyncFilterSubContent({
284
282
  {isTruncated ? (
285
283
  <Tooltip>
286
284
  <TooltipTrigger asChild>
287
- <span>{label.slice(0, 18) + '...'}</span>
285
+ <span>{label.slice(0, 18) + '…'}</span>
288
286
  </TooltipTrigger>
289
287
  <TooltipContent side="right">
290
288
  <p className="max-w-75 break-all">{label}</p>
@@ -306,14 +304,13 @@ const AsyncFilterSubContent = memo(function AsyncFilterSubContent({
306
304
  * Generic DataTable component with toolbar, filters, sorting and pagination.
307
305
  *
308
306
  * @template TData Row data shape
309
- * @template TValue Cell value shape
310
307
  */
311
- export function DataTable<TData, TValue>({
308
+ export function DataTable<TData extends RowData>({
312
309
  columns,
313
310
  data = [],
314
311
  isLoading = false,
315
312
  enableSearch = false,
316
- searchPlaceholder = 'Search...',
313
+ searchPlaceholder = 'Search…',
317
314
  initialSearch = '',
318
315
  totalCount,
319
316
  onParamsChange,
@@ -324,9 +321,10 @@ export function DataTable<TData, TValue>({
324
321
  initialSorting = [],
325
322
  filters = [],
326
323
  activeFilters = {}
327
- }: DataTableProps<TData, TValue>) {
324
+ }: DataTableProps<TData>) {
328
325
  const [sorting, setSorting] = useState<SortingState>(initialSorting)
329
- const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
326
+ const [columnVisibility, setColumnVisibility] =
327
+ useState<ColumnVisibilityState>({})
330
328
  const [searchValue, setSearchValue] = useState(initialSearch)
331
329
  const [pagination, setPagination] = useState({
332
330
  pageIndex: controlledPageIndex,
@@ -335,12 +333,41 @@ export function DataTable<TData, TValue>({
335
333
  /** Cache for async filter labels so pills display correctly after data refetch. */
336
334
  const [labelCache, setLabelCache] = useState<Record<string, string>>({})
337
335
  const searchInputId = useId()
338
- const lastTotalCountRef = useRef<number | undefined>(totalCount)
339
- /** Ref to always read the latest activeFilters inside async callbacks (avoids stale closures). */
336
+ /**
337
+ * Last positive totalCount. Used while loading so a transient 0 does not
338
+ * collapse the pagination footer (e.g. "Page 1 of 1" mid-refetch).
339
+ * Adjusted during render when the prop changes (React-approved pattern).
340
+ */
341
+ const [lastPositiveTotalCount, setLastPositiveTotalCount] = useState<
342
+ number | undefined
343
+ >(totalCount !== undefined && totalCount > 0 ? totalCount : undefined)
344
+ if (
345
+ totalCount !== undefined &&
346
+ totalCount > 0 &&
347
+ totalCount !== lastPositiveTotalCount
348
+ ) {
349
+ setLastPositiveTotalCount(totalCount)
350
+ }
351
+ const stableTotalCount =
352
+ isLoading && totalCount === 0 && lastPositiveTotalCount !== undefined
353
+ ? lastPositiveTotalCount
354
+ : totalCount
355
+
356
+ /** Latest values for debounced/async callbacks without putting them in effect deps. */
340
357
  const activeFiltersRef = useRef(activeFilters)
341
- activeFiltersRef.current = activeFilters
358
+ const onParamsChangeRef = useRef(onParamsChange)
342
359
  /** Set by the prop-sync effect so the debounced effect can skip redundant fires. */
343
360
  const isExternalPaginationSync = useRef(false)
361
+ /** Skip the initial render to avoid firing onParamsChange on mount. */
362
+ const isFirstRender = useRef(true)
363
+
364
+ useEffect(() => {
365
+ activeFiltersRef.current = activeFilters
366
+ }, [activeFilters])
367
+
368
+ useEffect(() => {
369
+ onParamsChangeRef.current = onParamsChange
370
+ }, [onParamsChange])
344
371
 
345
372
  // Keep internal pagination in sync with controlled props from parent.
346
373
  // Without this, external resets (e.g. filter change → page 1) are ignored.
@@ -350,7 +377,7 @@ export function DataTable<TData, TValue>({
350
377
  prev.pageIndex !== controlledPageIndex ||
351
378
  prev.pageSize !== controlledPageSize
352
379
  ) {
353
- // Flag so the debounced effect skips its next fire – the filter/clear
380
+ // Flag so the debounced effect skips its next fire: the filter/clear
354
381
  // handler that triggered this prop change already called onParamsChange.
355
382
  isExternalPaginationSync.current = true
356
383
  return {
@@ -362,30 +389,13 @@ export function DataTable<TData, TValue>({
362
389
  })
363
390
  }, [controlledPageIndex, controlledPageSize])
364
391
 
365
- // Preserve totalCount during loading to avoid pagination flicker (e.g. "Page 1 of 1" when switching pages).
366
- // Updating a ref synchronously during render is a safe, idempotent pattern documented by React
367
- // because refs are not reactive and this does not trigger a re-render.
368
- if (totalCount !== undefined && totalCount > 0) {
369
- lastTotalCountRef.current = totalCount
370
- }
371
- const stableTotalCount = useMemo(
372
- () =>
373
- isLoading && totalCount === 0 && lastTotalCountRef.current !== undefined
374
- ? lastTotalCountRef.current
375
- : totalCount,
376
- // lastTotalCountRef is a ref - intentionally omitted from deps
377
- [isLoading, totalCount]
378
- )
379
-
380
- // Initialise TanStack Table with manual pagination/sorting when enabled
381
- const table = useReactTable({
392
+ // TanStack Table v9: features (and row models) live on `dataTableFeatures`.
393
+ // Core row model is always included; sorted/paginated models only run when
394
+ // manual* is false.
395
+ const table = useTable({
396
+ features: dataTableFeatures,
382
397
  data,
383
398
  columns,
384
- getCoreRowModel: getCoreRowModel(),
385
- ...(!manualPagination && {
386
- getPaginationRowModel: getPaginationRowModel()
387
- }),
388
- ...(!manualSorting && { getSortedRowModel: getSortedRowModel() }),
389
399
  manualPagination,
390
400
  manualSorting,
391
401
  ...(manualPagination && {
@@ -402,16 +412,11 @@ export function DataTable<TData, TValue>({
402
412
  }
403
413
  })
404
414
 
405
- /** Skip the initial render to avoid firing onParamsChange on mount. */
406
- const isFirstRender = useRef(true)
407
-
408
- // Stable reference for onParamsChange to avoid unnecessary effect re-runs
409
- const onParamsChangeRef = useRef(onParamsChange)
410
- onParamsChangeRef.current = onParamsChange
411
-
412
415
  /**
413
416
  * Build the params object sent to onParamsChange.
414
- * Uses `activeFiltersRef` so async callbacks always read the latest value.
417
+ * Uses `activeFiltersRef` so debounced callbacks always read the latest filters
418
+ * without putting `activeFilters` in the debounce effect deps (filter handlers
419
+ * already call onParamsChange themselves).
415
420
  * Pass `overrides` to set page or filters explicitly (e.g. on filter change).
416
421
  */
417
422
  const buildParams = useCallback(
@@ -446,7 +451,7 @@ export function DataTable<TData, TValue>({
446
451
  )
447
452
 
448
453
  // Debounce param changes (300ms) to batch rapid user interactions.
449
- // Only watches pagination, sorting, and searchValue – NOT activeFilters,
454
+ // Only watches pagination, sorting, and searchValue, NOT activeFilters,
450
455
  // because filter handlers already call onParamsChange directly.
451
456
  useEffect(() => {
452
457
  if (isFirstRender.current) {
@@ -604,7 +609,7 @@ export function DataTable<TData, TValue>({
604
609
  }}
605
610
  className="justify-center text-center"
606
611
  >
607
- Clear filters
612
+ Clear Filters
608
613
  </DropdownMenuItem>
609
614
  </>
610
615
  )}
@@ -681,7 +686,7 @@ export function DataTable<TData, TValue>({
681
686
  variant="secondary"
682
687
  className="px-1 font-normal"
683
688
  >
684
- {displayLabel.slice(0, 18) + '...'}
689
+ {displayLabel.slice(0, 18) + '…'}
685
690
  </Badge>
686
691
  </TooltipTrigger>
687
692
  <TooltipContent>
@@ -792,10 +797,7 @@ export function DataTable<TData, TValue>({
792
797
  : undefined
793
798
  }
794
799
  >
795
- {flexRender(
796
- header.column.columnDef.header,
797
- header.getContext()
798
- )}
800
+ <table.FlexRender header={header} />
799
801
  {isSortable && (
800
802
  <div className="flex w-4 items-center justify-center">
801
803
  {sortStatus === 'asc' ? (
@@ -827,16 +829,10 @@ export function DataTable<TData, TValue>({
827
829
  <TableBody>
828
830
  {table.getRowModel().rows.length ? (
829
831
  table.getRowModel().rows.map(row => (
830
- <TableRow
831
- key={row.id}
832
- data-state={row.getIsSelected() ? 'selected' : undefined}
833
- >
832
+ <TableRow key={row.id}>
834
833
  {row.getVisibleCells().map(cell => (
835
834
  <TableCell key={cell.id}>
836
- {flexRender(
837
- cell.column.columnDef.cell,
838
- cell.getContext()
839
- )}
835
+ <table.FlexRender cell={cell} />
840
836
  </TableCell>
841
837
  ))}
842
838
  </TableRow>
@@ -958,3 +954,8 @@ export function DataTable<TData, TValue>({
958
954
  </div>
959
955
  )
960
956
  }
957
+
958
+ export { SortOrderEnum } from './enum'
959
+ export { dataTableFeatures } from './features'
960
+ export type { DataTableColumnDef } from './features'
961
+ export type { DataTableFilter, DataTableProps } from './types'
@@ -1,5 +1,6 @@
1
- import { ColumnDef, SortingState } from '@tanstack/react-table'
1
+ import type { RowData, SortingState } from '@tanstack/react-table'
2
2
  import { SortOrderEnum } from './enum'
3
+ import type { DataTableColumnDef } from './features'
3
4
 
4
5
  /** A single selectable option inside a filter dropdown. */
5
6
  interface DataTableFilterOption {
@@ -34,9 +35,9 @@ export interface DataTableFilter {
34
35
  * When `manualPagination` / `manualSorting` are enabled the parent is
35
36
  * responsible for fetching data and passing results back via `data` & `totalCount`.
36
37
  */
37
- export interface DataTableProps<TData, TValue> {
38
- /** Column definitions (TanStack Table). */
39
- columns: ColumnDef<TData, TValue>[]
38
+ export interface DataTableProps<TData extends RowData> {
39
+ /** Column definitions (TanStack Table v9, typed to DataTable features). */
40
+ columns: DataTableColumnDef<TData, any>[]
40
41
  /** Row data to display. */
41
42
  data?: TData[]
42
43
  /** Show skeleton loading rows while data is being fetched. */
@@ -1,8 +1,53 @@
1
+ /**
2
+ * @fileoverview Reusable Error Display Component
3
+ *
4
+ * Generic error/empty state component for displaying user-friendly
5
+ * error messages with optional icon and action button.
6
+ *
7
+ * **Use Cases:**
8
+ * - Empty search results
9
+ * - Data fetch failures
10
+ * - Access denied states
11
+ * - 404-like content not found
12
+ *
13
+ * @module components/error
14
+ */
15
+
1
16
  import { type LucideIcon, AlertTriangle } from 'lucide-react'
2
17
  import Link from 'next/link'
3
18
  import { FC } from 'react'
4
19
  import { Button } from '~/components/ui/button'
5
20
 
21
+ /**
22
+ * Error Display Component
23
+ *
24
+ * Renders a centered error state with icon, message, and action.
25
+ *
26
+ * **Layout:**
27
+ * - Large icon (customizable)
28
+ * - Bold title
29
+ * - Descriptive message
30
+ * - Action button (customizable link)
31
+ *
32
+ * @param {Object} props - Component props
33
+ * @param {string} props.title - Error heading text
34
+ * @param {string} props.message - Descriptive error message
35
+ * @param {LucideIcon} [props.icon] - Custom icon (defaults to AlertTriangle)
36
+ * @param {Object} [props.link] - Optional action button configuration
37
+ * @param {string} [props.link.href] - Button link destination (defaults to '/')
38
+ * @param {string} [props.link.text] - Button text (defaults to 'Browse Products')
39
+ * @returns {JSX.Element} Centered error display
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * <ErrorDisplay
44
+ * title="No Results Found"
45
+ * message="Try adjusting your search or filters"
46
+ * icon={Search}
47
+ * link={{ href: '/search', text: 'Clear Filters' }}
48
+ * />
49
+ * ```
50
+ */
6
51
  export const ErrorDisplay: FC<{
7
52
  title: string
8
53
  message: string
@@ -3,12 +3,40 @@
3
3
  import { File, FileText, Loader2, Music, Upload, Video, X } from 'lucide-react'
4
4
  import Image from 'next/image'
5
5
  import { useRef, useState } from 'react'
6
- import { toast } from 'sonner'
7
- import { generateUploadUrlAction } from '~/actions/uploads'
6
+ import { generateUploadUrlAction } from '~/app/actions/uploads'
8
7
  import { Button } from '~/components/ui/button'
9
8
  import { cn } from '~/lib/cn'
10
- import { MAX_FILE_SIZE_BYTES, MAX_FILE_SIZE_MB } from '~/lib/constants'
9
+ import {
10
+ MAX_FILE_SIZE_BYTES,
11
+ MAX_FILE_SIZE_MB,
12
+ R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
13
+ type R2UploadMimeType
14
+ } from '~/lib/constants'
15
+ import { toastErrorMessage, toastIfActionFailed } from '~/lib/toast-message'
16
+
17
+ function isAllowedMimeType(type: string): type is R2UploadMimeType {
18
+ return type in R2_UPLOAD_EXTENSION_BY_MIME_TYPE
19
+ }
20
+
21
+ function readFileAsDataUrl(file: File): Promise<string> {
22
+ return new Promise((resolve, reject) => {
23
+ const reader = new FileReader()
24
+ reader.onload = () => {
25
+ if (typeof reader.result === 'string') {
26
+ resolve(reader.result)
27
+ return
28
+ }
29
+ reject(new Error('Failed to read file'))
30
+ }
31
+ reader.onerror = () => {
32
+ reject(reader.error ?? new Error('Failed to read file'))
33
+ }
34
+ reader.readAsDataURL(file)
35
+ })
36
+ }
11
37
 
38
+ // Discriminated union so the preview renderer knows exactly what to show
39
+ // without guessing from a raw URL string.
12
40
  type PreviewState =
13
41
  | { kind: 'image'; url: string }
14
42
  | { kind: 'file'; name: string; mimeType: string }
@@ -16,6 +44,7 @@ type PreviewState =
16
44
 
17
45
  function getInitialPreview(currentUrl?: string | null): PreviewState {
18
46
  if (!currentUrl) return null
47
+ // base64 data URLs and image paths/URLs both render as images
19
48
  return { kind: 'image', url: currentUrl }
20
49
  }
21
50
 
@@ -32,7 +61,7 @@ function FileTypeIcon({ mimeType }: { mimeType: string }) {
32
61
  type Props = {
33
62
  /** MIME type filter passed to the file input, e.g. `"image/*"` or `"image/*,application/pdf"`. */
34
63
  accept: string
35
- /** Scopes the upload to a organization for cleanup tracking. */
64
+ /** Organization that owns the upload; the server checks you manage it. */
36
65
  organizationId: string
37
66
  /**
38
67
  * Called with the R2 object key (prod) or base64 data URL (dev mode) once
@@ -40,13 +69,13 @@ type Props = {
40
69
  */
41
70
  onClientUploadFinish: (key: string | null) => void
42
71
  /**
43
- * Pre-resolved URL for the current value - pass the output of
72
+ * Pre-resolved URL for the current value. Pass the output of
44
73
  * `resolveImageUrl` from the parent server component so the preview renders
45
74
  * correctly for existing R2 keys.
46
75
  */
47
76
  currentUrl?: string | null
48
77
  className?: string
49
- /** Passed to next/image `sizes` - should match the rendered widget width. */
78
+ /** Passed to next/image `sizes`. It should match the rendered widget width. */
50
79
  sizes?: string
51
80
  }
52
81
 
@@ -66,12 +95,18 @@ export function FileUpload({
66
95
 
67
96
  const handleFile = async (file: File) => {
68
97
  if (file.size > MAX_FILE_SIZE_BYTES) {
69
- toast.error(`File must be under ${MAX_FILE_SIZE_MB}MB`)
98
+ toastErrorMessage(`Choose a file under ${MAX_FILE_SIZE_MB} MB.`)
99
+ return
100
+ }
101
+
102
+ if (!isAllowedMimeType(file.type)) {
103
+ toastErrorMessage('Choose a JPEG, PNG, WebP, or GIF image.')
70
104
  return
71
105
  }
72
106
 
73
107
  setIsUploading(true)
74
108
 
109
+ // Set the preview from the file type without a network round trip.
75
110
  if (file.type.startsWith('image/')) {
76
111
  setPreview({ kind: 'image', url: URL.createObjectURL(file) })
77
112
  } else {
@@ -85,8 +120,13 @@ export function FileUpload({
85
120
  organizationId
86
121
  })
87
122
 
123
+ if (toastIfActionFailed(result)) {
124
+ setPreview(getInitialPreview(currentUrl))
125
+ setIsUploading(false)
126
+ return
127
+ }
88
128
  if (!result?.data) {
89
- toast.error('Failed to prepare upload. Please try again.')
129
+ toastErrorMessage('The upload could not start. Try again.')
90
130
  setPreview(getInitialPreview(currentUrl))
91
131
  setIsUploading(false)
92
132
  return
@@ -94,8 +134,8 @@ export function FileUpload({
94
134
 
95
135
  const { data } = result
96
136
 
97
- if (data.mode === 'r2') {
98
- try {
137
+ try {
138
+ if (data.mode === 'r2') {
99
139
  const res = await fetch(data.uploadUrl, {
100
140
  method: 'PUT',
101
141
  body: file,
@@ -103,27 +143,25 @@ export function FileUpload({
103
143
  })
104
144
  if (!res.ok) throw new Error(`R2 upload failed: ${res.status}`)
105
145
  onClientUploadFinish(data.key)
106
- } catch {
107
- toast.error('Upload failed. Please try again.')
108
- setPreview(getInitialPreview(currentUrl))
109
- }
110
- } else {
111
- const reader = new FileReader()
112
- reader.onload = e => {
113
- const base64 = e.target?.result as string
146
+ } else {
147
+ // Development mode has no R2 credentials, so store base64 data directly.
148
+ // Await the read so isUploading stays true until onClientUploadFinish runs.
149
+ const base64 = await readFileAsDataUrl(file)
114
150
  if (file.type.startsWith('image/')) {
115
151
  setPreview({ kind: 'image', url: base64 })
116
152
  }
117
153
  onClientUploadFinish(base64)
118
154
  }
119
- reader.onerror = () => {
120
- toast.error('Failed to read file. Please try again.')
121
- setPreview(getInitialPreview(currentUrl))
122
- }
123
- reader.readAsDataURL(file)
155
+ } catch {
156
+ toastErrorMessage(
157
+ data.mode === 'r2'
158
+ ? 'The upload failed. Check your connection and try again.'
159
+ : 'The file could not be read. Choose it again.'
160
+ )
161
+ setPreview(getInitialPreview(currentUrl))
162
+ } finally {
163
+ setIsUploading(false)
124
164
  }
125
-
126
- setIsUploading(false)
127
165
  }
128
166
 
129
167
  const handleClear = (e: React.MouseEvent) => {
@@ -187,7 +225,7 @@ export function FileUpload({
187
225
  Click to upload
188
226
  </span>
189
227
  <span className="text-muted-foreground/60 text-[11px]">
190
- Max {MAX_FILE_SIZE_MB}MB
228
+ Max {MAX_FILE_SIZE_MB} MB
191
229
  </span>
192
230
  </div>
193
231
  )}
@@ -206,6 +244,7 @@ export function FileUpload({
206
244
  size="icon"
207
245
  className="absolute -top-2 -right-2 h-6 w-6 rounded-full shadow-sm"
208
246
  onClick={handleClear}
247
+ aria-label="Remove file"
209
248
  >
210
249
  <X className="h-3 w-3" />
211
250
  </Button>
@@ -1,13 +1,29 @@
1
+ /**
2
+ * @fileoverview Page Transition Loader Component
3
+ *
4
+ * Top progress bar shown during navigation, using the theme primary color.
5
+ *
6
+ * @module components/loader
7
+ */
8
+
1
9
  'use client'
2
10
 
3
11
  import NextTopLoader from 'nextjs-toploader'
4
12
 
5
13
  import { FC } from 'react'
6
14
 
15
+ /**
16
+ * Page Loader Component
17
+ *
18
+ * Renders the NextTopLoader with preset configuration.
19
+ * Should be included once near the root layout.
20
+ *
21
+ * @returns {JSX.Element} Top loading bar component
22
+ */
7
23
  export const Loader: FC = () => {
8
24
  return (
9
25
  <NextTopLoader
10
- color="#777777"
26
+ color="var(--primary)"
11
27
  initialPosition={0.08}
12
28
  crawlSpeed={200}
13
29
  height={3}
@@ -15,7 +31,7 @@ export const Loader: FC = () => {
15
31
  showSpinner={true}
16
32
  easing="ease"
17
33
  speed={200}
18
- shadow={`0 0 10px '#777777',0 0 5px '#777777'`}
34
+ shadow="0 0 10px var(--primary), 0 0 5px var(--primary)"
19
35
  template='<div class="bar" role="bar"><div class="peg"></div></div>
20
36
  <div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'
21
37
  zIndex={10000}
@@ -0,0 +1,49 @@
1
+ import Image from 'next/image'
2
+ import { cn } from '~/lib/cn'
3
+ import { siteConfig } from '~/lib/siteConfig'
4
+
5
+ /**
6
+ * Brand mark, with the wordmark beside it unless `showName` is false.
7
+ *
8
+ * Two SVG files rather than one: the mark uses two colors, so `currentColor`
9
+ * cannot recolor it for both themes.
10
+ */
11
+ export function Logo({
12
+ className,
13
+ size = 36,
14
+ showName = true,
15
+ priority = false
16
+ }: {
17
+ className?: string
18
+ size?: number
19
+ showName?: boolean
20
+ priority?: boolean
21
+ }) {
22
+ const dimensions = { width: size, height: size }
23
+
24
+ return (
25
+ <span className={cn('flex items-center gap-2.5', className)}>
26
+ <Image
27
+ src={siteConfig.logo}
28
+ alt=""
29
+ aria-hidden
30
+ {...dimensions}
31
+ style={dimensions}
32
+ className="shrink-0 dark:hidden"
33
+ priority={priority}
34
+ />
35
+ <Image
36
+ src={siteConfig.logoDark}
37
+ alt=""
38
+ aria-hidden
39
+ {...dimensions}
40
+ style={dimensions}
41
+ className="hidden shrink-0 dark:block"
42
+ priority={priority}
43
+ />
44
+ <span className={cn('font-bold tracking-tight', !showName && 'sr-only')}>
45
+ {siteConfig.name}
46
+ </span>
47
+ </span>
48
+ )
49
+ }
@@ -32,7 +32,7 @@ export function NavigationSearch({
32
32
  groups,
33
33
  dialogDescription,
34
34
  emptyMessage = 'No results found.',
35
- buttonLabel = 'Search...',
35
+ buttonLabel = 'Search…',
36
36
  buttonClassName = 'text-muted-foreground relative h-9 w-36 justify-start rounded-md text-sm sm:w-64 sm:pr-12'
37
37
  }: NavigationSearchProps) {
38
38
  const router = useRouter()
@@ -111,7 +111,7 @@ export function NavigationSearch({
111
111
  description={dialogDescription}
112
112
  >
113
113
  <Command>
114
- <CommandInput placeholder="Type to search..." />
114
+ <CommandInput placeholder="Type to search…" />
115
115
  <CommandList>
116
116
  <CommandEmpty>{emptyMessage}</CommandEmpty>
117
117
 
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Reusable page heading component for admin panel pages.
3
+ * Renders a consistent icon, title and description layout at the top of each page.
4
+ * The `icon` prop is optional; when omitted only the text block is rendered.
5
+ * The `children` prop allows for additional elements (e.g. action buttons) to be placed on the right side of the heading.
6
+ */
1
7
  import { FC, ReactNode } from 'react'
2
8
 
3
9
  export const PageHeading = ({
@@ -1,12 +1,51 @@
1
+ /**
2
+ * @fileoverview React Query Provider Component
3
+ *
4
+ * Wraps the application with TanStack Query's QueryClientProvider.
5
+ * Enables data fetching, caching, and server state management.
6
+ *
7
+ * **Features Enabled:**
8
+ * - Automatic background refetching
9
+ * - Request deduplication
10
+ * - Cache management
11
+ * - Optimistic updates
12
+ * - useSafeActionQuery/useSafeActionMutation hooks
13
+ *
14
+ * @module components/react-query-provider
15
+ * @see https://tanstack.com/query/latest
16
+ */
17
+
1
18
  'use client'
2
19
 
3
20
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
- import { FC, ReactNode } from 'react'
21
+ import { FC, ReactNode, useState } from 'react'
22
+
23
+ /** Default: treat list/admin data as fresh for 30s so focus does not re-hit every action. */
24
+ const DEFAULT_STALE_TIME_MS = 30_000
25
+
26
+ function createQueryClient() {
27
+ return new QueryClient({
28
+ defaultOptions: {
29
+ queries: {
30
+ staleTime: DEFAULT_STALE_TIME_MS
31
+ }
32
+ }
33
+ })
34
+ }
5
35
 
36
+ /**
37
+ * React Query Provider Component
38
+ *
39
+ * Creates one QueryClient for the provider lifetime and wraps children.
40
+ * Place near the root of the client tree.
41
+ *
42
+ * Global `staleTime` is 30s. Live surfaces should pass `staleTime: 0`;
43
+ * heavy dashboards and static options can pass a longer value.
44
+ */
6
45
  export const ReactQueryProvider: FC<{
7
46
  children?: ReactNode
8
47
  }> = ({ children }) => {
9
- const client = new QueryClient({})
48
+ const [client] = useState(createQueryClient)
10
49
 
11
50
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>
12
51
  }