create-ej-app 26.0.0 → 28.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +79 -29
  3. package/package.json +2 -2
  4. package/template/api/.vscode/settings.json +1 -1
  5. package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
  6. package/template/api/CLAUDE.md +1 -0
  7. package/template/api/package.json +10 -10
  8. package/template/nextjs/.dockerignore +1 -0
  9. package/template/nextjs/AGENTS.md +260 -0
  10. package/template/nextjs/CLAUDE.md +1 -0
  11. package/template/nextjs/README.md +109 -0
  12. package/template/nextjs/bunfig.toml +3 -0
  13. package/template/nextjs/eslint.config.mjs +6 -5
  14. package/template/nextjs/gitignore +6 -1
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -39
  17. package/template/nextjs/public/logo-dark.svg +4 -0
  18. package/template/nextjs/public/logo.svg +4 -0
  19. package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
  20. package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  21. package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  22. package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  23. package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
  24. package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
  25. package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
  26. package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  27. package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  28. package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  29. package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  30. package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  31. package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
  32. package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
  33. package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
  34. package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
  35. package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  36. package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
  37. package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
  38. package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
  39. package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
  40. package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
  41. package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
  42. package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
  43. package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
  44. package/template/nextjs/src/app/error.tsx +37 -0
  45. package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  46. package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  47. package/template/nextjs/src/app/fonts/index.ts +60 -0
  48. package/template/nextjs/src/app/global-error.tsx +38 -0
  49. package/template/nextjs/src/app/globals.css +29 -0
  50. package/template/nextjs/src/app/layout.tsx +45 -18
  51. package/template/nextjs/src/app/not-found.tsx +25 -3
  52. package/template/nextjs/src/app/robots.ts +29 -0
  53. package/template/nextjs/src/app/sitemap.ts +21 -0
  54. package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
  55. package/template/nextjs/src/components/dashboard-header.tsx +70 -0
  56. package/template/nextjs/src/components/data-table/enum.ts +5 -0
  57. package/template/nextjs/src/components/data-table/features.ts +49 -0
  58. package/template/nextjs/src/components/data-table/index.tsx +961 -0
  59. package/template/nextjs/src/components/data-table/types.ts +76 -0
  60. package/template/nextjs/src/components/error.tsx +71 -0
  61. package/template/nextjs/src/components/loader.tsx +18 -2
  62. package/template/nextjs/src/components/logo.tsx +49 -0
  63. package/template/nextjs/src/components/navigation-search.tsx +140 -0
  64. package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
  65. package/template/nextjs/src/components/page-heading.tsx +38 -0
  66. package/template/nextjs/src/components/react-query-provider.tsx +41 -2
  67. package/template/nextjs/src/components/route-error.tsx +55 -0
  68. package/template/nextjs/src/components/screen-size.tsx +36 -0
  69. package/template/nextjs/src/components/site-footer.tsx +20 -4
  70. package/template/nextjs/src/components/theme-provider.tsx +48 -0
  71. package/template/nextjs/src/components/theme-toggle.tsx +58 -5
  72. package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
  73. package/template/nextjs/src/components/ui/avatar.tsx +112 -0
  74. package/template/nextjs/src/components/ui/badge.tsx +54 -0
  75. package/template/nextjs/src/components/ui/button.tsx +3 -1
  76. package/template/nextjs/src/components/ui/card.tsx +4 -4
  77. package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
  78. package/template/nextjs/src/components/ui/command.tsx +192 -0
  79. package/template/nextjs/src/components/ui/dialog.tsx +164 -0
  80. package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
  81. package/template/nextjs/src/components/ui/input-group.tsx +156 -0
  82. package/template/nextjs/src/components/ui/select.tsx +195 -0
  83. package/template/nextjs/src/components/ui/sheet.tsx +143 -0
  84. package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
  85. package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
  86. package/template/nextjs/src/components/ui/table.tsx +116 -0
  87. package/template/nextjs/src/components/ui/textarea.tsx +18 -0
  88. package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
  89. package/template/nextjs/src/components/user-menu.tsx +206 -0
  90. package/template/nextjs/src/db/common.ts +10 -0
  91. package/template/nextjs/src/db/index.ts +16 -6
  92. package/template/nextjs/src/db/relations.ts +21 -0
  93. package/template/nextjs/src/db/schema.ts +85 -46
  94. package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
  95. package/template/nextjs/src/emails/magic-link.tsx +15 -13
  96. package/template/nextjs/src/emails/test.tsx +11 -9
  97. package/template/nextjs/src/emails/welcome.tsx +12 -9
  98. package/template/nextjs/src/env.ts +1 -1
  99. package/template/nextjs/src/hooks/use-mobile.ts +14 -12
  100. package/template/nextjs/src/instrumentation.ts +20 -2
  101. package/template/nextjs/src/lib/actions.ts +55 -0
  102. package/template/nextjs/src/lib/admin-navigation.ts +43 -0
  103. package/template/nextjs/src/lib/auth-client.ts +20 -9
  104. package/template/nextjs/src/lib/auth-permissions.ts +61 -0
  105. package/template/nextjs/src/lib/auth.ts +33 -3
  106. package/template/nextjs/src/lib/callback-url.ts +28 -0
  107. package/template/nextjs/src/lib/constants.ts +21 -0
  108. package/template/nextjs/src/lib/delay.ts +10 -0
  109. package/template/nextjs/src/lib/email-service.ts +21 -0
  110. package/template/nextjs/src/lib/errors.ts +74 -0
  111. package/template/nextjs/src/lib/format-currency.ts +22 -24
  112. package/template/nextjs/src/lib/format-date.ts +69 -6
  113. package/template/nextjs/src/lib/nodemailer.ts +27 -3
  114. package/template/nextjs/src/lib/otel.ts +92 -2
  115. package/template/nextjs/src/lib/safe-action-client.ts +60 -9
  116. package/template/nextjs/src/lib/safe-action.ts +152 -16
  117. package/template/nextjs/src/lib/search.ts +16 -0
  118. package/template/nextjs/src/lib/seo.ts +7 -0
  119. package/template/nextjs/src/lib/siteConfig.ts +17 -1
  120. package/template/nextjs/src/lib/toast-message.ts +87 -1
  121. package/template/nextjs/src/lib/validations.ts +30 -0
  122. package/template/nextjs/src/proxy.ts +48 -0
  123. package/template/nextjs/src/scripts/reset-db.ts +22 -0
  124. package/template/nextjs/src/scripts/seed.ts +59 -8
  125. package/template/nextjs/src/scripts/test-mail.ts +28 -20
  126. package/template/nextjs/tests/admin/users.test.ts +64 -0
  127. package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
  128. package/template/nextjs/tests/db/seed.test.ts +49 -0
  129. package/template/nextjs/tests/helpers/action.ts +71 -0
  130. package/template/nextjs/tests/helpers/fixtures.ts +78 -0
  131. package/template/nextjs/tests/helpers/index.ts +2 -0
  132. package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
  133. package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
  134. package/template/nextjs/tests/lib/errors.test.ts +10 -0
  135. package/template/nextjs/tests/lib/search.test.ts +23 -0
  136. package/template/nextjs/tests/setup/pglite-db.ts +63 -0
  137. package/template/nextjs/tests/setup/preload.ts +95 -0
  138. package/template/nextjs/tsconfig.json +1 -0
  139. package/template/saas-starter/.dockerignore +1 -0
  140. package/template/saas-starter/.env.example +15 -15
  141. package/template/saas-starter/AGENTS.md +302 -0
  142. package/template/saas-starter/CLAUDE.md +1 -0
  143. package/template/saas-starter/README.md +124 -2
  144. package/template/saas-starter/bunfig.toml +3 -0
  145. package/template/saas-starter/eslint.config.mjs +6 -5
  146. package/template/saas-starter/gitignore +6 -1
  147. package/template/saas-starter/next.config.ts +19 -1
  148. package/template/saas-starter/package.json +50 -44
  149. package/template/saas-starter/public/logo-dark.svg +4 -0
  150. package/template/saas-starter/public/logo.svg +4 -0
  151. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  152. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  153. package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
  154. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
  155. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
  156. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  157. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  158. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
  159. package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
  160. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  161. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  162. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  163. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
  164. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  165. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  166. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  167. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  168. package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
  169. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  170. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  171. package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
  172. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  173. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  174. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
  175. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
  176. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
  177. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
  178. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
  179. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
  180. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
  181. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  182. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  188. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
  189. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
  190. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
  191. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  192. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
  193. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
  194. package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
  195. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
  196. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
  197. package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
  198. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  199. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  200. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  201. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  202. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  203. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
  204. package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
  205. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
  206. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
  207. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
  208. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  209. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  210. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  211. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  212. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  213. package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
  214. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  215. package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
  216. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
  217. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
  218. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  219. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  220. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  221. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  222. package/template/saas-starter/src/app/error.tsx +37 -0
  223. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  224. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  225. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  226. package/template/saas-starter/src/app/global-error.tsx +38 -0
  227. package/template/saas-starter/src/app/globals.css +29 -0
  228. package/template/saas-starter/src/app/layout.tsx +43 -19
  229. package/template/saas-starter/src/app/not-found.tsx +25 -3
  230. package/template/saas-starter/src/app/robots.ts +14 -10
  231. package/template/saas-starter/src/app/sitemap.ts +2 -1
  232. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  233. package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
  234. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  235. package/template/saas-starter/src/components/data-table/index.tsx +65 -64
  236. package/template/saas-starter/src/components/data-table/types.ts +5 -4
  237. package/template/saas-starter/src/components/error.tsx +45 -0
  238. package/template/saas-starter/src/components/file-upload.tsx +65 -26
  239. package/template/saas-starter/src/components/loader.tsx +18 -2
  240. package/template/saas-starter/src/components/logo.tsx +49 -0
  241. package/template/saas-starter/src/components/navigation-search.tsx +2 -2
  242. package/template/saas-starter/src/components/page-heading.tsx +6 -0
  243. package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
  244. package/template/saas-starter/src/components/route-error.tsx +55 -0
  245. package/template/saas-starter/src/components/screen-size.tsx +36 -0
  246. package/template/saas-starter/src/components/site-footer.tsx +20 -4
  247. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  248. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  249. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  250. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  251. package/template/saas-starter/src/components/theme-provider.tsx +48 -0
  252. package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
  253. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  254. package/template/saas-starter/src/components/ui/badge.tsx +6 -1
  255. package/template/saas-starter/src/components/ui/button.tsx +2 -0
  256. package/template/saas-starter/src/components/ui/command.tsx +1 -1
  257. package/template/saas-starter/src/components/user-menu.tsx +147 -24
  258. package/template/saas-starter/src/db/common.ts +33 -1
  259. package/template/saas-starter/src/db/enums.ts +24 -0
  260. package/template/saas-starter/src/db/index.ts +14 -5
  261. package/template/saas-starter/src/db/relations.ts +40 -3
  262. package/template/saas-starter/src/db/schema.ts +186 -35
  263. package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
  264. package/template/saas-starter/src/emails/invitation.tsx +34 -21
  265. package/template/saas-starter/src/emails/magic-link.tsx +15 -13
  266. package/template/saas-starter/src/emails/test.tsx +11 -9
  267. package/template/saas-starter/src/emails/welcome.tsx +12 -9
  268. package/template/saas-starter/src/env.ts +1 -1
  269. package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
  270. package/template/saas-starter/src/instrumentation.ts +20 -2
  271. package/template/saas-starter/src/lib/actions.ts +34 -0
  272. package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
  273. package/template/saas-starter/src/lib/app-navigation.ts +45 -41
  274. package/template/saas-starter/src/lib/auth-client.ts +8 -3
  275. package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
  276. package/template/saas-starter/src/lib/auth.ts +122 -21
  277. package/template/saas-starter/src/lib/billing.ts +59 -24
  278. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  279. package/template/saas-starter/src/lib/constants.ts +78 -9
  280. package/template/saas-starter/src/lib/delay.ts +10 -0
  281. package/template/saas-starter/src/lib/email-service.ts +30 -1
  282. package/template/saas-starter/src/lib/errors.ts +74 -0
  283. package/template/saas-starter/src/lib/format-currency.ts +22 -24
  284. package/template/saas-starter/src/lib/format-date.ts +69 -6
  285. package/template/saas-starter/src/lib/image-url.ts +54 -0
  286. package/template/saas-starter/src/lib/nodemailer.ts +27 -3
  287. package/template/saas-starter/src/lib/organization-access.ts +56 -76
  288. package/template/saas-starter/src/lib/otel.ts +92 -2
  289. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  290. package/template/saas-starter/src/lib/plans.ts +40 -13
  291. package/template/saas-starter/src/lib/rbac.ts +44 -0
  292. package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
  293. package/template/saas-starter/src/lib/safe-action.ts +137 -17
  294. package/template/saas-starter/src/lib/search.ts +16 -0
  295. package/template/saas-starter/src/lib/seo.ts +7 -0
  296. package/template/saas-starter/src/lib/siteConfig.ts +17 -1
  297. package/template/saas-starter/src/lib/storage.ts +176 -46
  298. package/template/saas-starter/src/lib/stripe.ts +4 -1
  299. package/template/saas-starter/src/lib/toast-message.ts +87 -1
  300. package/template/saas-starter/src/lib/validations.ts +85 -2
  301. package/template/saas-starter/src/proxy.ts +48 -0
  302. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  303. package/template/saas-starter/src/scripts/seed.ts +82 -8
  304. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  305. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
  306. package/template/saas-starter/src/scripts/test-mail.ts +28 -20
  307. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  308. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  309. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  310. package/template/saas-starter/tests/helpers/action.ts +71 -0
  311. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  312. package/template/saas-starter/tests/helpers/index.ts +2 -0
  313. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  314. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  315. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  316. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  317. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  318. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  319. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  320. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  321. package/template/saas-starter/tests/setup/preload.ts +108 -0
  322. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  323. package/template/saas-starter/tsconfig.json +1 -0
  324. package/cli.js +0 -147
  325. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  326. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  327. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  328. package/template/nextjs/terraform/backend-service.tf +0 -13
  329. package/template/nextjs/terraform/cleanup.sh +0 -9
  330. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  331. package/template/nextjs/terraform/cloudrun.tf +0 -68
  332. package/template/nextjs/terraform/database.tf +0 -60
  333. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  334. package/template/nextjs/terraform/gitignore +0 -4
  335. package/template/nextjs/terraform/ip-address.tf +0 -11
  336. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  337. package/template/nextjs/terraform/network.tf +0 -21
  338. package/template/nextjs/terraform/pre.sh +0 -3
  339. package/template/nextjs/terraform/providers.tf +0 -18
  340. package/template/nextjs/terraform/secret.txt +0 -11
  341. package/template/nextjs/terraform/setup.sh +0 -25
  342. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  343. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  344. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  345. package/template/nextjs/terraform/terraform.tfvars +0 -4
  346. package/template/nextjs/terraform/url-map.tf +0 -23
  347. package/template/nextjs/terraform/variables.tf +0 -21
  348. package/template/saas-starter/next-env.d.ts +0 -6
  349. package/template/saas-starter/src/actions/uploads.ts +0 -28
  350. package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
  351. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
  352. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
  353. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
  354. package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
  355. package/template/saas-starter/src/app/proxy.ts +0 -28
  356. package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
  357. package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
@@ -0,0 +1,961 @@
1
+ /**
2
+ * @fileoverview Generic DataTable built on TanStack Table.
3
+ *
4
+ * Supports:
5
+ * - Server-side (manual) or client-side pagination & sorting
6
+ * - Toolbar search input
7
+ * - Static and async (server-fetched) column filters
8
+ * - Column visibility toggle
9
+ * - Skeleton loading states
10
+ * - Active-filter pill bar with individual / bulk clear
11
+ *
12
+ * @module components/data-table
13
+ */
14
+ 'use client'
15
+
16
+ import {
17
+ type ColumnVisibilityState,
18
+ type RowData,
19
+ type SortingState,
20
+ useTable
21
+ } from '@tanstack/react-table'
22
+ import {
23
+ ArrowDown,
24
+ ArrowUp,
25
+ ArrowUpDown,
26
+ Check,
27
+ ChevronLeft,
28
+ ChevronRight,
29
+ ChevronsLeft,
30
+ ChevronsRight,
31
+ Filter,
32
+ Loader2,
33
+ Search,
34
+ SlidersHorizontal,
35
+ X
36
+ } from 'lucide-react'
37
+ import React, {
38
+ memo,
39
+ useCallback,
40
+ useEffect,
41
+ useId,
42
+ useMemo,
43
+ useRef,
44
+ useState
45
+ } from 'react'
46
+ import { Badge } from '~/components/ui/badge'
47
+ import { Button } from '~/components/ui/button'
48
+ import {
49
+ Command,
50
+ CommandEmpty,
51
+ CommandGroup,
52
+ CommandInput,
53
+ CommandItem,
54
+ CommandList
55
+ } from '~/components/ui/command'
56
+ import {
57
+ DropdownMenu,
58
+ DropdownMenuCheckboxItem,
59
+ DropdownMenuContent,
60
+ DropdownMenuItem,
61
+ DropdownMenuLabel,
62
+ DropdownMenuSeparator,
63
+ DropdownMenuSub,
64
+ DropdownMenuSubContent,
65
+ DropdownMenuSubTrigger,
66
+ DropdownMenuTrigger
67
+ } from '~/components/ui/dropdown-menu'
68
+ import { Input } from '~/components/ui/input'
69
+ import {
70
+ Select,
71
+ SelectContent,
72
+ SelectItem,
73
+ SelectTrigger,
74
+ SelectValue
75
+ } from '~/components/ui/select'
76
+ import {
77
+ Table,
78
+ TableBody,
79
+ TableCell,
80
+ TableHead,
81
+ TableHeader,
82
+ TableRow
83
+ } from '~/components/ui/table'
84
+ import {
85
+ Tooltip,
86
+ TooltipContent,
87
+ TooltipTrigger
88
+ } from '~/components/ui/tooltip'
89
+ import { cn } from '~/lib/cn'
90
+ import { useSafeActionQuery } from '~/lib/safe-action-client'
91
+ import { Skeleton } from '../ui/skeleton'
92
+ import { SortOrderEnum } from './enum'
93
+ import { dataTableFeatures } from './features'
94
+ import { DataTableFilter, DataTableProps } from './types'
95
+
96
+ /**
97
+ * Filter sub-content for filters with pre-defined static options.
98
+ * Renders a searchable command list with checkboxes for multi-select.
99
+ */
100
+ const StaticFilterSubContent = memo(function StaticFilterSubContent({
101
+ filter,
102
+ activeFilters,
103
+ onSelect
104
+ }: {
105
+ filter: DataTableFilter
106
+ activeFilters: Record<string, (string | boolean)[]>
107
+ onSelect: (values: (string | boolean)[]) => void
108
+ }) {
109
+ // Convert all selected values to strings for uniform Set comparison
110
+ const selectedValues = new Set((activeFilters[filter.id] || []).map(String))
111
+ return (
112
+ <Command>
113
+ <CommandInput placeholder={filter.label} />
114
+ <CommandList>
115
+ <CommandEmpty>No results found.</CommandEmpty>
116
+ <CommandGroup>
117
+ {filter.options?.map(option => {
118
+ const isSelected = selectedValues.has(String(option.value))
119
+ const isTruncated = option.label && option.label.length > 18
120
+ return (
121
+ <CommandItem
122
+ key={String(option.value)}
123
+ className="data-selected:bg-accent data-selected:text-accent-foreground"
124
+ onSelect={() => {
125
+ const newSet = new Set(selectedValues)
126
+ if (isSelected) {
127
+ newSet.delete(String(option.value))
128
+ } else {
129
+ newSet.add(String(option.value))
130
+ }
131
+ // Map back to original values
132
+ const values = Array.from(newSet).map(v => {
133
+ const op = filter.options?.find(o => String(o.value) === v)
134
+ return op ? op.value : v
135
+ })
136
+ onSelect(values)
137
+ }}
138
+ >
139
+ <div
140
+ className={cn(
141
+ 'mr-2 flex size-4 shrink-0 items-center justify-center rounded-lg border transition-colors',
142
+ isSelected
143
+ ? 'border-primary bg-primary text-primary-foreground'
144
+ : 'border-input bg-background text-transparent'
145
+ )}
146
+ >
147
+ <Check
148
+ className={cn(
149
+ 'size-3',
150
+ isSelected
151
+ ? 'text-primary-foreground!'
152
+ : 'text-transparent'
153
+ )}
154
+ />
155
+ </div>
156
+ {option.icon && (
157
+ <option.icon className="text-muted-foreground mr-2 h-4 w-4" />
158
+ )}
159
+ {isTruncated ? (
160
+ <Tooltip>
161
+ <TooltipTrigger asChild>
162
+ <span>{option.label.slice(0, 18) + '…'}</span>
163
+ </TooltipTrigger>
164
+ <TooltipContent side="right">
165
+ <p className="max-w-75 break-all">{option.label}</p>
166
+ </TooltipContent>
167
+ </Tooltip>
168
+ ) : (
169
+ <span>{option.label}</span>
170
+ )}
171
+ </CommandItem>
172
+ )
173
+ })}
174
+ </CommandGroup>
175
+ </CommandList>
176
+ </Command>
177
+ )
178
+ })
179
+
180
+ /**
181
+ * Filter sub-content for async filters whose options are fetched
182
+ * from the server via a safe-action. Includes debounced search input.
183
+ */
184
+ const AsyncFilterSubContent = memo(function AsyncFilterSubContent({
185
+ filter,
186
+ activeFilters,
187
+ onSelect,
188
+ onCacheLabels
189
+ }: {
190
+ filter: DataTableFilter
191
+ activeFilters: Record<string, (string | boolean)[]>
192
+ onSelect: (values: string[]) => void
193
+ onCacheLabels: (labels: Record<string, string>) => void
194
+ }) {
195
+ const selectedValues = new Set((activeFilters[filter.id] || []).map(String))
196
+ const [search, setSearch] = useState('')
197
+ const [debouncedSearch, setDebouncedSearch] = useState('')
198
+
199
+ // Debounce the search input to avoid excessive server calls
200
+ useEffect(() => {
201
+ const timer = setTimeout(() => setDebouncedSearch(search), 500)
202
+ return () => clearTimeout(timer)
203
+ }, [search])
204
+
205
+ // Fetch filter options from the server action with debounced search
206
+ const { data, isPending } = useSafeActionQuery(
207
+ filter.queryKey || filter.id,
208
+ filter.action,
209
+ {
210
+ search: debouncedSearch,
211
+ limit: 20,
212
+ page: 1
213
+ }
214
+ )
215
+
216
+ // Normalise various server response shapes into a flat array
217
+ const items = useMemo<{ label: string; value: string | number | boolean }[]>(
218
+ () =>
219
+ (data || []).map((item: any) => ({
220
+ label: item.label ?? item.name ?? item.id,
221
+ value: item.value ?? item.id
222
+ })),
223
+ [data]
224
+ )
225
+
226
+ return (
227
+ <Command shouldFilter={false}>
228
+ <CommandInput
229
+ placeholder={filter.label}
230
+ value={search}
231
+ onValueChange={setSearch}
232
+ />
233
+ <CommandList>
234
+ {isPending && (
235
+ <div className="flex items-center justify-center py-4">
236
+ <Loader2 className="h-4 w-4 animate-spin" />
237
+ </div>
238
+ )}
239
+ {!isPending && items.length === 0 && (
240
+ <CommandEmpty>No results found.</CommandEmpty>
241
+ )}
242
+ <CommandGroup>
243
+ {items.map(item => {
244
+ const value = String(item.value)
245
+ const label = item.label
246
+ const isSelected = selectedValues.has(value)
247
+ const isTruncated = label && label.length > 18
248
+ return (
249
+ <CommandItem
250
+ key={value}
251
+ className="data-selected:bg-accent data-selected:text-accent-foreground"
252
+ onSelect={() => {
253
+ const newSet = new Set(selectedValues)
254
+ if (isSelected) {
255
+ newSet.delete(value)
256
+ } else {
257
+ newSet.add(value)
258
+ // Update cache when selecting an item
259
+ onCacheLabels({ [value]: label })
260
+ }
261
+ onSelect(Array.from(newSet))
262
+ }}
263
+ >
264
+ <div
265
+ className={cn(
266
+ 'mr-2 flex size-4 shrink-0 items-center justify-center rounded-lg border transition-colors',
267
+ isSelected
268
+ ? 'border-primary bg-primary text-primary-foreground'
269
+ : 'border-input bg-background text-transparent'
270
+ )}
271
+ >
272
+ <Check
273
+ className={cn(
274
+ 'size-3',
275
+ isSelected
276
+ ? 'text-primary-foreground!'
277
+ : 'text-transparent'
278
+ )}
279
+ />
280
+ </div>
281
+ {/* Truncate long labels for better readability */}
282
+ {isTruncated ? (
283
+ <Tooltip>
284
+ <TooltipTrigger asChild>
285
+ <span>{label.slice(0, 18) + '…'}</span>
286
+ </TooltipTrigger>
287
+ <TooltipContent side="right">
288
+ <p className="max-w-75 break-all">{label}</p>
289
+ </TooltipContent>
290
+ </Tooltip>
291
+ ) : (
292
+ <span>{label}</span>
293
+ )}
294
+ </CommandItem>
295
+ )
296
+ })}
297
+ </CommandGroup>
298
+ </CommandList>
299
+ </Command>
300
+ )
301
+ })
302
+
303
+ /**
304
+ * Generic DataTable component with toolbar, filters, sorting and pagination.
305
+ *
306
+ * @template TData Row data shape
307
+ */
308
+ export function DataTable<TData extends RowData>({
309
+ columns,
310
+ data = [],
311
+ isLoading = false,
312
+ enableSearch = false,
313
+ searchPlaceholder = 'Search…',
314
+ initialSearch = '',
315
+ totalCount,
316
+ onParamsChange,
317
+ manualPagination = false,
318
+ manualSorting = false,
319
+ pageIndex: controlledPageIndex = 0,
320
+ pageSize: controlledPageSize = 10,
321
+ initialSorting = [],
322
+ filters = [],
323
+ activeFilters = {}
324
+ }: DataTableProps<TData>) {
325
+ const [sorting, setSorting] = useState<SortingState>(initialSorting)
326
+ const [columnVisibility, setColumnVisibility] =
327
+ useState<ColumnVisibilityState>({})
328
+ const [searchValue, setSearchValue] = useState(initialSearch)
329
+ const [pagination, setPagination] = useState({
330
+ pageIndex: controlledPageIndex,
331
+ pageSize: controlledPageSize
332
+ })
333
+ /** Cache for async filter labels so pills display correctly after data refetch. */
334
+ const [labelCache, setLabelCache] = useState<Record<string, string>>({})
335
+ const searchInputId = useId()
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. */
357
+ const activeFiltersRef = useRef(activeFilters)
358
+ const onParamsChangeRef = useRef(onParamsChange)
359
+ /** Set by the prop-sync effect so the debounced effect can skip redundant fires. */
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])
371
+
372
+ // Keep internal pagination in sync with controlled props from parent.
373
+ // Without this, external resets (e.g. filter change → page 1) are ignored.
374
+ useEffect(() => {
375
+ setPagination(prev => {
376
+ if (
377
+ prev.pageIndex !== controlledPageIndex ||
378
+ prev.pageSize !== controlledPageSize
379
+ ) {
380
+ // Flag so the debounced effect skips its next fire: the filter/clear
381
+ // handler that triggered this prop change already called onParamsChange.
382
+ isExternalPaginationSync.current = true
383
+ return {
384
+ pageIndex: controlledPageIndex,
385
+ pageSize: controlledPageSize
386
+ }
387
+ }
388
+ return prev
389
+ })
390
+ }, [controlledPageIndex, controlledPageSize])
391
+
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,
397
+ data,
398
+ columns,
399
+ manualPagination,
400
+ manualSorting,
401
+ ...(manualPagination && {
402
+ pageCount:
403
+ Math.ceil((stableTotalCount ?? data.length) / pagination.pageSize) || 1
404
+ }),
405
+ onSortingChange: setSorting,
406
+ onColumnVisibilityChange: setColumnVisibility,
407
+ onPaginationChange: setPagination,
408
+ state: {
409
+ sorting,
410
+ columnVisibility,
411
+ pagination
412
+ }
413
+ })
414
+
415
+ /**
416
+ * Build the params object sent to onParamsChange.
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).
420
+ * Pass `overrides` to set page or filters explicitly (e.g. on filter change).
421
+ */
422
+ const buildParams = useCallback(
423
+ (
424
+ overrides?: Partial<{
425
+ page: number
426
+ filters: Record<string, (string | boolean)[]> | undefined
427
+ }>
428
+ ) => {
429
+ const sort = sorting[0]
430
+ const filtersValue =
431
+ overrides && 'filters' in overrides
432
+ ? overrides.filters
433
+ : activeFiltersRef.current &&
434
+ Object.keys(activeFiltersRef.current).length > 0
435
+ ? activeFiltersRef.current
436
+ : undefined
437
+ return {
438
+ page: overrides?.page ?? pagination.pageIndex + 1,
439
+ limit: pagination.pageSize,
440
+ sortBy: sort?.id,
441
+ sortOrder: sort
442
+ ? sort.desc
443
+ ? SortOrderEnum.DESC
444
+ : SortOrderEnum.ASC
445
+ : undefined,
446
+ search: searchValue || undefined,
447
+ filters: filtersValue
448
+ }
449
+ },
450
+ [pagination.pageIndex, pagination.pageSize, searchValue, sorting]
451
+ )
452
+
453
+ // Debounce param changes (300ms) to batch rapid user interactions.
454
+ // Only watches pagination, sorting, and searchValue, NOT activeFilters,
455
+ // because filter handlers already call onParamsChange directly.
456
+ useEffect(() => {
457
+ if (isFirstRender.current) {
458
+ isFirstRender.current = false
459
+ return
460
+ }
461
+
462
+ // Skip when the pagination change came from an external prop sync
463
+ // (e.g. parent reset page after a filter handler already called onParamsChange).
464
+ if (isExternalPaginationSync.current) {
465
+ isExternalPaginationSync.current = false
466
+ return
467
+ }
468
+
469
+ if (!onParamsChangeRef.current) return
470
+
471
+ const timeout = setTimeout(() => {
472
+ onParamsChangeRef.current?.(buildParams())
473
+ }, 300)
474
+
475
+ return () => clearTimeout(timeout)
476
+ }, [pagination, sorting, searchValue, buildParams])
477
+
478
+ /** Whether any filter has at least one selected value. */
479
+ const hasAnyFilter = useMemo(
480
+ () => Object.values(activeFilters).some(f => f.length > 0),
481
+ [activeFilters]
482
+ )
483
+
484
+ /** Sum of all selected filter values across all filters (shown as badge count). */
485
+ const totalActiveFilterCount = useMemo(
486
+ () =>
487
+ Object.values(activeFilters).reduce((acc, curr) => acc + curr.length, 0),
488
+ [activeFilters]
489
+ )
490
+
491
+ /** Stable callback for async filter sub-components to update label cache. */
492
+ const handleCacheLabels = useCallback((newLabels: Record<string, string>) => {
493
+ setLabelCache(prev => ({ ...prev, ...newLabels }))
494
+ }, [])
495
+
496
+ /**
497
+ * Stable factory for per-filter onSelect handlers.
498
+ * Returns a memoized handler for each filter id so sub-components
499
+ * receive a stable reference and avoid unnecessary re-renders.
500
+ */
501
+ const makeFilterSelectHandler = useCallback(
502
+ (filterId: string) => (values: (string | boolean)[]) => {
503
+ const newFilters: Record<string, (string | boolean)[]> = {
504
+ ...(activeFilters || {})
505
+ }
506
+ if (values.length > 0) {
507
+ newFilters[filterId] = values
508
+ } else {
509
+ delete newFilters[filterId]
510
+ }
511
+ onParamsChange?.(
512
+ buildParams({
513
+ page: 1,
514
+ filters: Object.keys(newFilters).length > 0 ? newFilters : undefined
515
+ })
516
+ )
517
+ },
518
+ [activeFilters, buildParams, onParamsChange]
519
+ )
520
+
521
+ return (
522
+ <div className="space-y-4">
523
+ {/* ── Toolbar: search, filter dropdown, column visibility toggle ── */}
524
+ <div className="flex items-center justify-between gap-2">
525
+ <div className="flex flex-1 items-center gap-2">
526
+ {enableSearch && (
527
+ <div className="relative max-w-sm flex-1">
528
+ <Search
529
+ className="text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4"
530
+ aria-hidden
531
+ />
532
+ <label htmlFor={searchInputId} className="sr-only">
533
+ {searchPlaceholder}
534
+ </label>
535
+ <Input
536
+ id={searchInputId}
537
+ type="search"
538
+ placeholder={searchPlaceholder}
539
+ value={searchValue}
540
+ onChange={event => {
541
+ setSearchValue(event.target.value)
542
+ // Reset to first page when search query changes
543
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
544
+ }}
545
+ className="pl-8"
546
+ aria-label={searchPlaceholder}
547
+ />
548
+ </div>
549
+ )}
550
+ {filters.length > 0 && (
551
+ <DropdownMenu>
552
+ <DropdownMenuTrigger asChild>
553
+ <Button
554
+ variant="outline"
555
+ size="sm"
556
+ className="gap-1"
557
+ aria-label="Filter"
558
+ >
559
+ <Filter className="h-4 w-4" />
560
+ {totalActiveFilterCount > 0 && (
561
+ <Badge
562
+ variant="secondary"
563
+ className="pointer-events-none ml-1 h-5 min-w-5 px-1"
564
+ >
565
+ {totalActiveFilterCount}
566
+ </Badge>
567
+ )}
568
+ </Button>
569
+ </DropdownMenuTrigger>
570
+ <DropdownMenuContent align="start" className="w-50">
571
+ <DropdownMenuLabel>Filter by</DropdownMenuLabel>
572
+ <DropdownMenuSeparator />
573
+ {/* Render each filter as a sub-menu with either async or static content */}
574
+ {filters.map(filter => (
575
+ <DropdownMenuSub key={filter.id}>
576
+ <DropdownMenuSubTrigger>
577
+ {filter.label}
578
+ </DropdownMenuSubTrigger>
579
+ <DropdownMenuSubContent className="w-50 p-0">
580
+ {filter.action ? (
581
+ <AsyncFilterSubContent
582
+ filter={filter}
583
+ activeFilters={activeFilters}
584
+ onCacheLabels={handleCacheLabels}
585
+ onSelect={
586
+ makeFilterSelectHandler(filter.id) as (
587
+ values: string[]
588
+ ) => void
589
+ }
590
+ />
591
+ ) : (
592
+ <StaticFilterSubContent
593
+ filter={filter}
594
+ activeFilters={activeFilters}
595
+ onSelect={makeFilterSelectHandler(filter.id)}
596
+ />
597
+ )}
598
+ </DropdownMenuSubContent>
599
+ </DropdownMenuSub>
600
+ ))}
601
+ {hasAnyFilter && (
602
+ <>
603
+ <DropdownMenuSeparator />
604
+ <DropdownMenuItem
605
+ onSelect={() => {
606
+ onParamsChange?.(
607
+ buildParams({ page: 1, filters: undefined })
608
+ )
609
+ }}
610
+ className="justify-center text-center"
611
+ >
612
+ Clear Filters
613
+ </DropdownMenuItem>
614
+ </>
615
+ )}
616
+ </DropdownMenuContent>
617
+ </DropdownMenu>
618
+ )}
619
+ </div>
620
+ {/* Column visibility toggle dropdown */}
621
+ <DropdownMenu>
622
+ <DropdownMenuTrigger asChild>
623
+ <Button
624
+ variant="outline"
625
+ size="sm"
626
+ className="gap-1"
627
+ aria-label="Toggle columns"
628
+ >
629
+ <SlidersHorizontal className="h-4 w-4" />
630
+ </Button>
631
+ </DropdownMenuTrigger>
632
+ <DropdownMenuContent align="end" className="w-40">
633
+ <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
634
+ <DropdownMenuSeparator />
635
+ {table
636
+ .getAllColumns()
637
+ .filter(column => column.getCanHide())
638
+ .map(column => {
639
+ return (
640
+ <DropdownMenuCheckboxItem
641
+ key={column.id}
642
+ className="capitalize"
643
+ checked={column.getIsVisible()}
644
+ onCheckedChange={value => column.toggleVisibility(!!value)}
645
+ onSelect={e => e.preventDefault()}
646
+ >
647
+ {column.id}
648
+ </DropdownMenuCheckboxItem>
649
+ )
650
+ })}
651
+ </DropdownMenuContent>
652
+ </DropdownMenu>
653
+ </div>
654
+
655
+ {/* ── Active filter pills bar ── */}
656
+ {hasAnyFilter && (
657
+ <div className="flex flex-wrap gap-2">
658
+ {filters.map(filter => {
659
+ const selected = activeFilters[filter.id]
660
+ if (!selected || selected.length === 0) return null
661
+ return (
662
+ <div
663
+ key={filter.id}
664
+ className="bg-accent/50 flex items-center gap-1 rounded-md px-2 py-1 text-sm"
665
+ >
666
+ <span className="font-medium">{filter.label}:</span>
667
+ <div className="flex gap-1">
668
+ {selected.length > 2 ? (
669
+ <Badge variant="secondary" className="px-1 font-normal">
670
+ {selected.length} selected
671
+ </Badge>
672
+ ) : (
673
+ selected.map(val => {
674
+ const displayLabel =
675
+ filter.options?.find(
676
+ o => String(o.value) === String(val)
677
+ )?.label ||
678
+ labelCache[String(val)] ||
679
+ String(val)
680
+ const isTruncated =
681
+ displayLabel && displayLabel.length > 18
682
+ return isTruncated ? (
683
+ <Tooltip key={String(val)}>
684
+ <TooltipTrigger asChild>
685
+ <Badge
686
+ variant="secondary"
687
+ className="px-1 font-normal"
688
+ >
689
+ {displayLabel.slice(0, 18) + '…'}
690
+ </Badge>
691
+ </TooltipTrigger>
692
+ <TooltipContent>
693
+ <p className="max-w-75 break-all">{displayLabel}</p>
694
+ </TooltipContent>
695
+ </Tooltip>
696
+ ) : (
697
+ <Badge
698
+ key={String(val)}
699
+ variant="secondary"
700
+ className="px-1 font-normal"
701
+ >
702
+ {displayLabel}
703
+ </Badge>
704
+ )
705
+ })
706
+ )}
707
+ </div>
708
+ <Button
709
+ variant="ghost"
710
+ className="hover:bg-muted ml-1 h-4 w-4 rounded-full p-0"
711
+ aria-label={`Clear ${filter.label} filter`}
712
+ onClick={() => {
713
+ const newFilters = { ...activeFilters }
714
+ delete newFilters[filter.id]
715
+ onParamsChange?.(
716
+ buildParams({
717
+ page: 1,
718
+ filters:
719
+ Object.keys(newFilters).length > 0
720
+ ? newFilters
721
+ : undefined
722
+ })
723
+ )
724
+ }}
725
+ >
726
+ <X className="h-3 w-3" />
727
+ </Button>
728
+ </div>
729
+ )
730
+ })}
731
+ <Button
732
+ variant="ghost"
733
+ className="h-7 px-2 text-xs"
734
+ onClick={() => {
735
+ onParamsChange?.(buildParams({ page: 1, filters: undefined }))
736
+ }}
737
+ >
738
+ Reset
739
+ </Button>
740
+ </div>
741
+ )}
742
+
743
+ {/* ── Table body ── */}
744
+ <div className="bg-card relative rounded-md border">
745
+ {isLoading && data.length > 0 && (
746
+ <div
747
+ role="status"
748
+ aria-label="Loading"
749
+ aria-live="polite"
750
+ className="bg-background/50 absolute inset-0 z-10 flex items-center justify-center rounded-md"
751
+ >
752
+ <Loader2
753
+ className="text-muted-foreground h-6 w-6 animate-spin"
754
+ aria-hidden
755
+ />
756
+ </div>
757
+ )}
758
+ <Table aria-busy={isLoading} aria-label="Data table">
759
+ <TableHeader>
760
+ {table.getHeaderGroups().map(headerGroup => (
761
+ <TableRow key={headerGroup.id}>
762
+ {headerGroup.headers.map(header => {
763
+ const isSortable = header.column.getCanSort()
764
+ const sortStatus = header.column.getIsSorted()
765
+
766
+ return (
767
+ <TableHead key={header.id} colSpan={header.colSpan}>
768
+ {header.isPlaceholder ? null : (
769
+ <div
770
+ role={isSortable ? 'button' : undefined}
771
+ tabIndex={isSortable ? 0 : undefined}
772
+ aria-sort={
773
+ isSortable
774
+ ? sortStatus === 'asc'
775
+ ? 'ascending'
776
+ : sortStatus === 'desc'
777
+ ? 'descending'
778
+ : 'none'
779
+ : undefined
780
+ }
781
+ className={cn(
782
+ 'flex items-center gap-2',
783
+ isSortable &&
784
+ 'hover:text-foreground cursor-pointer select-none'
785
+ )}
786
+ onClick={header.column.getToggleSortingHandler()}
787
+ onKeyDown={
788
+ isSortable
789
+ ? e => {
790
+ if (e.key === 'Enter' || e.key === ' ') {
791
+ e.preventDefault()
792
+ header.column.getToggleSortingHandler()?.(
793
+ e as unknown as React.MouseEvent
794
+ )
795
+ }
796
+ }
797
+ : undefined
798
+ }
799
+ >
800
+ <table.FlexRender header={header} />
801
+ {isSortable && (
802
+ <div className="flex w-4 items-center justify-center">
803
+ {sortStatus === 'asc' ? (
804
+ <ArrowUp
805
+ className="h-4 w-4"
806
+ aria-label="Sorted ascending"
807
+ />
808
+ ) : sortStatus === 'desc' ? (
809
+ <ArrowDown
810
+ className="h-4 w-4"
811
+ aria-label="Sorted descending"
812
+ />
813
+ ) : (
814
+ <ArrowUpDown
815
+ className="h-4 w-4 opacity-30"
816
+ aria-label="Not sorted"
817
+ />
818
+ )}
819
+ </div>
820
+ )}
821
+ </div>
822
+ )}
823
+ </TableHead>
824
+ )
825
+ })}
826
+ </TableRow>
827
+ ))}
828
+ </TableHeader>
829
+ <TableBody>
830
+ {table.getRowModel().rows.length ? (
831
+ table.getRowModel().rows.map(row => (
832
+ <TableRow key={row.id}>
833
+ {row.getVisibleCells().map(cell => (
834
+ <TableCell key={cell.id}>
835
+ <table.FlexRender cell={cell} />
836
+ </TableCell>
837
+ ))}
838
+ </TableRow>
839
+ ))
840
+ ) : isLoading ? (
841
+ Array.from({ length: pagination.pageSize }).map((_, index) => (
842
+ // Using array index as key is acceptable for static skeleton placeholders
843
+ <TableRow key={index} aria-hidden>
844
+ {table.getVisibleFlatColumns().map(col => (
845
+ <TableCell key={col.id}>
846
+ <Skeleton className="h-4 w-full" />
847
+ </TableCell>
848
+ ))}
849
+ </TableRow>
850
+ ))
851
+ ) : (
852
+ <TableRow>
853
+ <TableCell
854
+ colSpan={columns.length}
855
+ className="h-24 text-center"
856
+ role="cell"
857
+ >
858
+ No results
859
+ </TableCell>
860
+ </TableRow>
861
+ )}
862
+ </TableBody>
863
+ </Table>
864
+ </div>
865
+
866
+ {/* ── Pagination footer ── */}
867
+ <div className="flex flex-col items-center justify-between gap-4 px-2 sm:flex-row sm:gap-0">
868
+ <div className="text-muted-foreground flex-1 text-center text-sm sm:text-left">
869
+ {stableTotalCount !== undefined ? (
870
+ stableTotalCount === 0 ? (
871
+ <>No results</>
872
+ ) : (
873
+ <>
874
+ Showing {pagination.pageIndex * pagination.pageSize + 1} to{' '}
875
+ {Math.min(
876
+ (pagination.pageIndex + 1) * pagination.pageSize,
877
+ stableTotalCount
878
+ )}{' '}
879
+ of {stableTotalCount} results
880
+ </>
881
+ )
882
+ ) : (
883
+ <>{data.length} row(s) loaded.</>
884
+ )}
885
+ </div>
886
+ <div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-6">
887
+ <div className="flex items-center gap-2">
888
+ <p className="text-sm font-medium whitespace-nowrap">
889
+ Rows per page
890
+ </p>
891
+ <Select
892
+ value={`${pagination.pageSize}`}
893
+ onValueChange={value => {
894
+ table.setPageSize(Number(value))
895
+ }}
896
+ >
897
+ <SelectTrigger className="h-8 w-20">
898
+ <SelectValue placeholder={pagination.pageSize} />
899
+ </SelectTrigger>
900
+ <SelectContent side="top">
901
+ {[10, 20, 50, 100].map(pageSize => (
902
+ <SelectItem key={pageSize} value={`${pageSize}`}>
903
+ {pageSize}
904
+ </SelectItem>
905
+ ))}
906
+ </SelectContent>
907
+ </Select>
908
+ </div>
909
+ <div className="flex items-center gap-2">
910
+ <div className="flex w-25 items-center justify-center text-sm font-medium">
911
+ Page {pagination.pageIndex + 1} of {table.getPageCount() || 1}
912
+ </div>
913
+ <div className="flex items-center gap-1">
914
+ <Button
915
+ variant="outline"
916
+ className="h-8 w-8 p-0"
917
+ onClick={() => table.setPageIndex(0)}
918
+ disabled={!table.getCanPreviousPage()}
919
+ >
920
+ <span className="sr-only">Go to first page</span>
921
+ <ChevronsLeft className="h-4 w-4" />
922
+ </Button>
923
+ <Button
924
+ variant="outline"
925
+ className="h-8 w-8 p-0"
926
+ onClick={() => table.previousPage()}
927
+ disabled={!table.getCanPreviousPage()}
928
+ >
929
+ <span className="sr-only">Go to previous page</span>
930
+ <ChevronLeft className="h-4 w-4" />
931
+ </Button>
932
+ <Button
933
+ variant="outline"
934
+ className="h-8 w-8 p-0"
935
+ onClick={() => table.nextPage()}
936
+ disabled={!table.getCanNextPage()}
937
+ >
938
+ <span className="sr-only">Go to next page</span>
939
+ <ChevronRight className="h-4 w-4" />
940
+ </Button>
941
+ <Button
942
+ variant="outline"
943
+ className="h-8 w-8 p-0"
944
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
945
+ disabled={!table.getCanNextPage()}
946
+ >
947
+ <span className="sr-only">Go to last page</span>
948
+ <ChevronsRight className="h-4 w-4" />
949
+ </Button>
950
+ </div>
951
+ </div>
952
+ </div>
953
+ </div>
954
+ </div>
955
+ )
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'