create-ej-app 25.0.0 → 27.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/README.md +125 -226
  2. package/cli.ts +83 -28
  3. package/package.json +2 -2
  4. package/template/api/.vscode/settings.json +1 -1
  5. package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
  6. package/template/api/CLAUDE.md +1 -0
  7. package/template/api/package.json +10 -10
  8. package/template/nextjs/.dockerignore +1 -0
  9. package/template/nextjs/AGENTS.md +260 -0
  10. package/template/nextjs/CLAUDE.md +1 -0
  11. package/template/nextjs/README.md +109 -0
  12. package/template/nextjs/bunfig.toml +3 -0
  13. package/template/nextjs/eslint.config.mjs +6 -5
  14. package/template/nextjs/gitignore +6 -0
  15. package/template/nextjs/next.config.ts +8 -1
  16. package/template/nextjs/package.json +47 -40
  17. package/template/nextjs/public/logo-dark.svg +4 -0
  18. package/template/nextjs/public/logo.svg +4 -0
  19. package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
  20. package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  21. package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  22. package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  23. package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
  24. package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
  25. package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
  26. package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  27. package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  28. package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  29. package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  30. package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  31. package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
  32. package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
  33. package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
  34. package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
  35. package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  36. package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
  37. package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
  38. package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
  39. package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
  40. package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
  41. package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
  42. package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
  43. package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
  44. package/template/nextjs/src/app/error.tsx +37 -0
  45. package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  46. package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  47. package/template/nextjs/src/app/fonts/index.ts +60 -0
  48. package/template/nextjs/src/app/global-error.tsx +38 -0
  49. package/template/nextjs/src/app/globals.css +29 -0
  50. package/template/nextjs/src/app/layout.tsx +45 -18
  51. package/template/nextjs/src/app/not-found.tsx +25 -3
  52. package/template/nextjs/src/app/robots.ts +29 -0
  53. package/template/nextjs/src/app/sitemap.ts +21 -0
  54. package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
  55. package/template/nextjs/src/components/dashboard-header.tsx +70 -0
  56. package/template/nextjs/src/components/data-table/enum.ts +5 -0
  57. package/template/nextjs/src/components/data-table/features.ts +49 -0
  58. package/template/nextjs/src/components/data-table/index.tsx +961 -0
  59. package/template/nextjs/src/components/data-table/types.ts +76 -0
  60. package/template/nextjs/src/components/error.tsx +71 -0
  61. package/template/nextjs/src/components/loader.tsx +18 -2
  62. package/template/nextjs/src/components/logo.tsx +49 -0
  63. package/template/nextjs/src/components/navigation-search.tsx +140 -0
  64. package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
  65. package/template/nextjs/src/components/page-heading.tsx +38 -0
  66. package/template/nextjs/src/components/react-query-provider.tsx +41 -2
  67. package/template/nextjs/src/components/route-error.tsx +55 -0
  68. package/template/nextjs/src/components/screen-size.tsx +36 -0
  69. package/template/nextjs/src/components/site-footer.tsx +20 -4
  70. package/template/nextjs/src/components/theme-provider.tsx +48 -0
  71. package/template/nextjs/src/components/theme-toggle.tsx +58 -5
  72. package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
  73. package/template/nextjs/src/components/ui/avatar.tsx +112 -0
  74. package/template/nextjs/src/components/ui/badge.tsx +54 -0
  75. package/template/nextjs/src/components/ui/button.tsx +3 -1
  76. package/template/nextjs/src/components/ui/card.tsx +4 -4
  77. package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
  78. package/template/nextjs/src/components/ui/command.tsx +192 -0
  79. package/template/nextjs/src/components/ui/dialog.tsx +164 -0
  80. package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
  81. package/template/nextjs/src/components/ui/input-group.tsx +156 -0
  82. package/template/nextjs/src/components/ui/select.tsx +195 -0
  83. package/template/nextjs/src/components/ui/sheet.tsx +143 -0
  84. package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
  85. package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
  86. package/template/nextjs/src/components/ui/table.tsx +116 -0
  87. package/template/nextjs/src/components/ui/textarea.tsx +18 -0
  88. package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
  89. package/template/nextjs/src/components/user-menu.tsx +206 -0
  90. package/template/nextjs/src/db/common.ts +10 -0
  91. package/template/nextjs/src/db/index.ts +16 -6
  92. package/template/nextjs/src/db/relations.ts +21 -0
  93. package/template/nextjs/src/db/schema.ts +85 -46
  94. package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
  95. package/template/nextjs/src/emails/magic-link.tsx +15 -13
  96. package/template/nextjs/src/emails/test.tsx +11 -9
  97. package/template/nextjs/src/emails/welcome.tsx +12 -9
  98. package/template/nextjs/src/env.ts +1 -1
  99. package/template/nextjs/src/hooks/use-mobile.ts +14 -12
  100. package/template/nextjs/src/instrumentation.ts +20 -2
  101. package/template/nextjs/src/lib/actions.ts +55 -0
  102. package/template/nextjs/src/lib/admin-navigation.ts +43 -0
  103. package/template/nextjs/src/lib/auth-client.ts +20 -9
  104. package/template/nextjs/src/lib/auth-permissions.ts +61 -0
  105. package/template/nextjs/src/lib/auth.ts +33 -3
  106. package/template/nextjs/src/lib/callback-url.ts +28 -0
  107. package/template/nextjs/src/lib/constants.ts +21 -0
  108. package/template/nextjs/src/lib/delay.ts +10 -0
  109. package/template/nextjs/src/lib/email-service.ts +21 -0
  110. package/template/nextjs/src/lib/errors.ts +74 -0
  111. package/template/nextjs/src/lib/format-currency.ts +22 -24
  112. package/template/nextjs/src/lib/format-date.ts +69 -6
  113. package/template/nextjs/src/lib/nodemailer.ts +27 -3
  114. package/template/nextjs/src/lib/otel.ts +92 -2
  115. package/template/nextjs/src/lib/safe-action-client.ts +60 -9
  116. package/template/nextjs/src/lib/safe-action.ts +152 -16
  117. package/template/nextjs/src/lib/search.ts +16 -0
  118. package/template/nextjs/src/lib/seo.ts +7 -0
  119. package/template/nextjs/src/lib/siteConfig.ts +17 -1
  120. package/template/nextjs/src/lib/toast-message.ts +87 -1
  121. package/template/nextjs/src/lib/validations.ts +30 -0
  122. package/template/nextjs/src/proxy.ts +48 -0
  123. package/template/nextjs/src/scripts/reset-db.ts +22 -0
  124. package/template/nextjs/src/scripts/seed.ts +59 -8
  125. package/template/nextjs/src/scripts/test-mail.ts +28 -20
  126. package/template/nextjs/tests/admin/users.test.ts +64 -0
  127. package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
  128. package/template/nextjs/tests/db/seed.test.ts +49 -0
  129. package/template/nextjs/tests/helpers/action.ts +71 -0
  130. package/template/nextjs/tests/helpers/fixtures.ts +78 -0
  131. package/template/nextjs/tests/helpers/index.ts +2 -0
  132. package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
  133. package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
  134. package/template/nextjs/tests/lib/errors.test.ts +10 -0
  135. package/template/nextjs/tests/lib/search.test.ts +23 -0
  136. package/template/nextjs/tests/setup/pglite-db.ts +63 -0
  137. package/template/nextjs/tests/setup/preload.ts +95 -0
  138. package/template/nextjs/tsconfig.json +1 -0
  139. package/template/saas-starter/.dockerignore +8 -0
  140. package/template/saas-starter/.env.example +25 -0
  141. package/template/saas-starter/.vscode/extensions.json +11 -0
  142. package/template/saas-starter/.vscode/settings.json +3 -0
  143. package/template/saas-starter/AGENTS.md +302 -0
  144. package/template/saas-starter/CLAUDE.md +1 -0
  145. package/template/saas-starter/Dockerfile +15 -0
  146. package/template/saas-starter/README.md +125 -0
  147. package/template/saas-starter/bunfig.toml +3 -0
  148. package/template/saas-starter/components.json +25 -0
  149. package/template/saas-starter/drizzle.config.ts +10 -0
  150. package/template/saas-starter/eslint.config.mjs +24 -0
  151. package/template/saas-starter/gitignore +14 -0
  152. package/template/saas-starter/next.config.ts +32 -0
  153. package/template/saas-starter/package.json +97 -0
  154. package/template/saas-starter/postcss.config.mjs +7 -0
  155. package/template/saas-starter/prettier.config.mjs +11 -0
  156. package/template/saas-starter/public/logo-dark.svg +4 -0
  157. package/template/saas-starter/public/logo.svg +4 -0
  158. package/template/saas-starter/public/placeholder.svg +1 -0
  159. package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
  160. package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
  161. package/template/saas-starter/src/app/(admin)/actions/users.ts +183 -0
  162. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
  163. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  164. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
  165. package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
  166. package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
  167. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +53 -0
  168. package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
  169. package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
  170. package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
  171. package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
  172. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +377 -0
  173. package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
  174. package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
  175. package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
  176. package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
  177. package/template/saas-starter/src/app/(app)/actions/billing.ts +216 -0
  178. package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
  179. package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
  180. package/template/saas-starter/src/app/(app)/actions/members.ts +189 -0
  181. package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
  182. package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
  183. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +17 -0
  184. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
  185. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
  186. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
  187. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
  188. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
  189. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
  190. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
  191. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +168 -0
  192. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
  193. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
  194. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
  195. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
  196. package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
  197. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +31 -0
  198. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
  199. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
  200. package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
  201. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +37 -0
  202. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
  203. package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
  204. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
  205. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
  206. package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
  207. package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
  208. package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
  209. package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
  210. package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
  211. package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
  212. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +131 -0
  213. package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
  214. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
  215. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
  216. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
  217. package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
  218. package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
  219. package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
  220. package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
  221. package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
  222. package/template/saas-starter/src/app/(marketing)/layout.tsx +48 -0
  223. package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
  224. package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
  225. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
  226. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
  227. package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
  228. package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
  229. package/template/saas-starter/src/app/actions/uploads.ts +36 -0
  230. package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
  231. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  232. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  233. package/template/saas-starter/src/app/error.tsx +37 -0
  234. package/template/saas-starter/src/app/favicon.ico +0 -0
  235. package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
  236. package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
  237. package/template/saas-starter/src/app/fonts/index.ts +60 -0
  238. package/template/saas-starter/src/app/global-error.tsx +38 -0
  239. package/template/saas-starter/src/app/globals.css +269 -0
  240. package/template/saas-starter/src/app/layout.tsx +77 -0
  241. package/template/saas-starter/src/app/not-found.tsx +49 -0
  242. package/template/saas-starter/src/app/robots.ts +36 -0
  243. package/template/saas-starter/src/app/sitemap.ts +21 -0
  244. package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
  245. package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
  246. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  247. package/template/saas-starter/src/components/data-table/features.ts +49 -0
  248. package/template/saas-starter/src/components/data-table/index.tsx +961 -0
  249. package/template/saas-starter/src/components/data-table/types.ts +76 -0
  250. package/template/saas-starter/src/components/error.tsx +71 -0
  251. package/template/saas-starter/src/components/file-upload.tsx +254 -0
  252. package/template/saas-starter/src/components/loader.tsx +41 -0
  253. package/template/saas-starter/src/components/logo.tsx +49 -0
  254. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  255. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  256. package/template/saas-starter/src/components/page-heading.tsx +38 -0
  257. package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
  258. package/template/saas-starter/src/components/route-error.tsx +55 -0
  259. package/template/saas-starter/src/components/screen-size.tsx +76 -0
  260. package/template/saas-starter/src/components/site-footer.tsx +39 -0
  261. package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
  262. package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
  263. package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
  264. package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
  265. package/template/saas-starter/src/components/theme-provider.tsx +115 -0
  266. package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
  267. package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
  268. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  269. package/template/saas-starter/src/components/ui/badge.tsx +54 -0
  270. package/template/saas-starter/src/components/ui/button.tsx +69 -0
  271. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  272. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  273. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  274. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  275. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  276. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  277. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  278. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  279. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  280. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  281. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  282. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  283. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  284. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  285. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  286. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  287. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  288. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  289. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  290. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  291. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  292. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  293. package/template/saas-starter/src/components/user-menu.tsx +213 -0
  294. package/template/saas-starter/src/db/common.ts +63 -0
  295. package/template/saas-starter/src/db/enums.ts +50 -0
  296. package/template/saas-starter/src/db/index.ts +60 -0
  297. package/template/saas-starter/src/db/relations.ts +112 -0
  298. package/template/saas-starter/src/db/schema.ts +342 -0
  299. package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
  300. package/template/saas-starter/src/emails/invitation.tsx +69 -0
  301. package/template/saas-starter/src/emails/magic-link.tsx +51 -0
  302. package/template/saas-starter/src/emails/test.tsx +34 -0
  303. package/template/saas-starter/src/emails/welcome.tsx +37 -0
  304. package/template/saas-starter/src/env.ts +66 -0
  305. package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
  306. package/template/saas-starter/src/instrumentation.ts +44 -0
  307. package/template/saas-starter/src/lib/actions.ts +55 -0
  308. package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
  309. package/template/saas-starter/src/lib/app-navigation.ts +63 -0
  310. package/template/saas-starter/src/lib/auth-client.ts +34 -0
  311. package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
  312. package/template/saas-starter/src/lib/auth.ts +312 -0
  313. package/template/saas-starter/src/lib/billing.ts +152 -0
  314. package/template/saas-starter/src/lib/callback-url.ts +28 -0
  315. package/template/saas-starter/src/lib/cn.ts +4 -0
  316. package/template/saas-starter/src/lib/constants.ts +91 -0
  317. package/template/saas-starter/src/lib/delay.ts +12 -0
  318. package/template/saas-starter/src/lib/email-service.ts +118 -0
  319. package/template/saas-starter/src/lib/errors.ts +74 -0
  320. package/template/saas-starter/src/lib/format-currency.ts +25 -0
  321. package/template/saas-starter/src/lib/format-date.ts +75 -0
  322. package/template/saas-starter/src/lib/image-url.ts +54 -0
  323. package/template/saas-starter/src/lib/nodemailer.ts +67 -0
  324. package/template/saas-starter/src/lib/organization-access.ts +73 -0
  325. package/template/saas-starter/src/lib/otel.ts +117 -0
  326. package/template/saas-starter/src/lib/plan-limits.ts +37 -0
  327. package/template/saas-starter/src/lib/plans.ts +90 -0
  328. package/template/saas-starter/src/lib/rbac.ts +44 -0
  329. package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
  330. package/template/saas-starter/src/lib/safe-action.ts +187 -0
  331. package/template/saas-starter/src/lib/search.ts +16 -0
  332. package/template/saas-starter/src/lib/seo.ts +7 -0
  333. package/template/saas-starter/src/lib/siteConfig.ts +20 -0
  334. package/template/saas-starter/src/lib/storage.ts +311 -0
  335. package/template/saas-starter/src/lib/stripe.ts +9 -0
  336. package/template/saas-starter/src/lib/toast-message.ts +101 -0
  337. package/template/saas-starter/src/lib/validations.ts +95 -0
  338. package/template/saas-starter/src/proxy.ts +48 -0
  339. package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
  340. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  341. package/template/saas-starter/src/scripts/seed.ts +85 -0
  342. package/template/saas-starter/src/scripts/setup-db.ts +72 -0
  343. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
  344. package/template/saas-starter/src/scripts/test-mail.ts +43 -0
  345. package/template/saas-starter/tests/admin/users.test.ts +64 -0
  346. package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
  347. package/template/saas-starter/tests/db/seed.test.ts +57 -0
  348. package/template/saas-starter/tests/helpers/action.ts +71 -0
  349. package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
  350. package/template/saas-starter/tests/helpers/index.ts +2 -0
  351. package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
  352. package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
  353. package/template/saas-starter/tests/lib/errors.test.ts +10 -0
  354. package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
  355. package/template/saas-starter/tests/lib/search.test.ts +23 -0
  356. package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
  357. package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
  358. package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
  359. package/template/saas-starter/tests/setup/preload.ts +108 -0
  360. package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
  361. package/template/saas-starter/tsconfig.json +52 -0
  362. package/cli.js +0 -143
  363. package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
  364. package/template/nextjs/src/app/(app)/layout.tsx +0 -20
  365. package/template/nextjs/terraform/artifact-registry.tf +0 -5
  366. package/template/nextjs/terraform/backend-service.tf +0 -13
  367. package/template/nextjs/terraform/cleanup.sh +0 -9
  368. package/template/nextjs/terraform/cloudbuild.tf +0 -70
  369. package/template/nextjs/terraform/cloudrun.tf +0 -68
  370. package/template/nextjs/terraform/database.tf +0 -60
  371. package/template/nextjs/terraform/forwarding-rule.tf +0 -43
  372. package/template/nextjs/terraform/gitignore +0 -4
  373. package/template/nextjs/terraform/ip-address.tf +0 -11
  374. package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
  375. package/template/nextjs/terraform/network.tf +0 -21
  376. package/template/nextjs/terraform/pre.sh +0 -3
  377. package/template/nextjs/terraform/providers.tf +0 -18
  378. package/template/nextjs/terraform/secret.txt +0 -11
  379. package/template/nextjs/terraform/setup.sh +0 -25
  380. package/template/nextjs/terraform/ssl-certs.tf +0 -32
  381. package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
  382. package/template/nextjs/terraform/target-https-proxy.tf +0 -14
  383. package/template/nextjs/terraform/terraform.tfvars +0 -4
  384. package/template/nextjs/terraform/url-map.tf +0 -23
  385. package/template/nextjs/terraform/variables.tf +0 -21
@@ -0,0 +1,76 @@
1
+ import type { RowData, SortingState } from '@tanstack/react-table'
2
+ import { SortOrderEnum } from './enum'
3
+ import type { DataTableColumnDef } from './features'
4
+
5
+ /** A single selectable option inside a filter dropdown. */
6
+ interface DataTableFilterOption {
7
+ label: string
8
+ value: string | boolean
9
+ /** Optional icon rendered next to the option label. */
10
+ icon?: React.ComponentType<{ className?: string }>
11
+ }
12
+
13
+ /**
14
+ * Describes a filter that appears in the DataTable toolbar.
15
+ * Filters can either be static (pre-defined `options`) or async
16
+ * (fetched via a server `action` on search).
17
+ */
18
+ export interface DataTableFilter {
19
+ /** Unique key used to store the filter's selected values. */
20
+ id: string
21
+ /** Human-readable label shown in the filter dropdown trigger. */
22
+ label: string
23
+ /** Pre-defined options for static filters. Omit for async filters. */
24
+ options?: DataTableFilterOption[]
25
+ /** Server action called to fetch options dynamically (async filters). */
26
+ action?: any
27
+ /** Custom React-Query key for async filter caching. Defaults to `id`. */
28
+ queryKey?: string
29
+ }
30
+
31
+ /**
32
+ * Props accepted by the generic `DataTable` component.
33
+ *
34
+ * Supports both client-side and server-side (manual) pagination & sorting.
35
+ * When `manualPagination` / `manualSorting` are enabled the parent is
36
+ * responsible for fetching data and passing results back via `data` & `totalCount`.
37
+ */
38
+ export interface DataTableProps<TData extends RowData> {
39
+ /** Column definitions (TanStack Table v9, typed to DataTable features). */
40
+ columns: DataTableColumnDef<TData, any>[]
41
+ /** Row data to display. */
42
+ data?: TData[]
43
+ /** Show skeleton loading rows while data is being fetched. */
44
+ isLoading?: boolean
45
+ /** Render the search input in the toolbar. */
46
+ enableSearch?: boolean
47
+ /** Placeholder text for the search input. */
48
+ searchPlaceholder?: string
49
+ /** Initial value for the search input (e.g. restored from URL query params). */
50
+ initialSearch?: string
51
+ /** Total row count for server-side pagination info. */
52
+ totalCount?: number
53
+ /** Callback fired when pagination, sorting, search or filters change. */
54
+ onParamsChange?: (params: {
55
+ page: number
56
+ limit: number
57
+ sortBy?: string
58
+ sortOrder?: SortOrderEnum
59
+ search?: string
60
+ filters?: Record<string, (string | boolean)[]>
61
+ }) => void
62
+ /** Enable server-side pagination (parent controls data fetching). */
63
+ manualPagination?: boolean
64
+ /** Enable server-side sorting (parent controls data fetching). */
65
+ manualSorting?: boolean
66
+ /** Current 0-based page index (controlled). */
67
+ pageIndex?: number
68
+ /** Number of rows per page (controlled). */
69
+ pageSize?: number
70
+ /** Default sorting state applied on mount. */
71
+ initialSorting?: SortingState
72
+ /** Filter definitions rendered in the toolbar filter dropdown. */
73
+ filters?: DataTableFilter[]
74
+ /** Currently active filter selections keyed by filter id. */
75
+ activeFilters?: Record<string, (string | boolean)[]>
76
+ }
@@ -0,0 +1,71 @@
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
+
16
+ import { type LucideIcon, AlertTriangle } from 'lucide-react'
17
+ import Link from 'next/link'
18
+ import { FC } from 'react'
19
+ import { Button } from '~/components/ui/button'
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
+ */
51
+ export const ErrorDisplay: FC<{
52
+ title: string
53
+ message: string
54
+ icon?: LucideIcon
55
+ link?: {
56
+ href?: string
57
+ text?: string
58
+ }
59
+ }> = ({ title, message, icon, link }) => {
60
+ const Icon = icon || AlertTriangle
61
+ return (
62
+ <div className="flex w-full flex-col items-center justify-center py-16">
63
+ <Icon className="text-muted-foreground/50 size-16" />
64
+ <h2 className="mt-4 text-xl font-semibold">{title}</h2>
65
+ <p className="text-muted-foreground mt-2">{message}</p>
66
+ <Button className="mt-4" asChild>
67
+ <Link href={link?.href || '/'}>{link?.text || 'Go Home'}</Link>
68
+ </Button>
69
+ </div>
70
+ )
71
+ }
@@ -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
+ }
@@ -0,0 +1,140 @@
1
+ 'use client'
2
+
3
+ import { Search } from 'lucide-react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { useCallback, useEffect, useMemo, useState } from 'react'
6
+ import {
7
+ SidebarNavGroup,
8
+ SidebarNavItem,
9
+ SidebarNavSubItem
10
+ } from '~/components/navigation-sidebar'
11
+ import { Button } from '~/components/ui/button'
12
+ import {
13
+ Command,
14
+ CommandDialog,
15
+ CommandEmpty,
16
+ CommandGroup,
17
+ CommandInput,
18
+ CommandItem,
19
+ CommandList,
20
+ CommandSeparator
21
+ } from '~/components/ui/command'
22
+
23
+ type NavigationSearchProps = {
24
+ groups: SidebarNavGroup[]
25
+ dialogDescription: string
26
+ emptyMessage?: string
27
+ buttonLabel?: string
28
+ buttonClassName?: string
29
+ }
30
+
31
+ export function NavigationSearch({
32
+ groups,
33
+ dialogDescription,
34
+ emptyMessage = 'No results found.',
35
+ buttonLabel = 'Search…',
36
+ buttonClassName = 'text-muted-foreground relative h-9 w-36 justify-start rounded-md text-sm sm:w-64 sm:pr-12'
37
+ }: NavigationSearchProps) {
38
+ const router = useRouter()
39
+ const [searchOpen, setSearchOpen] = useState(false)
40
+
41
+ useEffect(() => {
42
+ const onKeyDown = (event: KeyboardEvent) => {
43
+ if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
44
+ event.preventDefault()
45
+ setSearchOpen(true)
46
+ }
47
+ }
48
+
49
+ document.addEventListener('keydown', onKeyDown)
50
+ return () => document.removeEventListener('keydown', onKeyDown)
51
+ }, [])
52
+
53
+ const navigate = useCallback(
54
+ (url: string) => {
55
+ setSearchOpen(false)
56
+ router.push(url)
57
+ },
58
+ [router]
59
+ )
60
+
61
+ const hasGroups = groups.length > 0
62
+
63
+ const normalizedGroups = useMemo(
64
+ () =>
65
+ groups.map(group => ({
66
+ label: group.label,
67
+ items: group.items.flatMap((item: SidebarNavItem) => {
68
+ if (item.items?.length) {
69
+ return item.items.map((subItem: SidebarNavSubItem) => ({
70
+ key: subItem.url,
71
+ label: `${item.title} › ${subItem.title}`,
72
+ value: `${group.label} ${item.title} ${subItem.title}`,
73
+ url: subItem.url,
74
+ icon: item.icon
75
+ }))
76
+ }
77
+
78
+ return [
79
+ {
80
+ key: item.url,
81
+ label: item.title,
82
+ value: `${group.label} ${item.title}`,
83
+ url: item.url,
84
+ icon: item.icon
85
+ }
86
+ ]
87
+ })
88
+ })),
89
+ [groups]
90
+ )
91
+
92
+ return (
93
+ <>
94
+ <Button
95
+ variant="outline"
96
+ className={buttonClassName}
97
+ onClick={() => setSearchOpen(true)}
98
+ disabled={!hasGroups}
99
+ >
100
+ <Search className="mr-2 size-4" />
101
+ <span>{buttonLabel}</span>
102
+ <kbd className="bg-muted pointer-events-none absolute top-[50%] right-1.5 hidden h-5 -translate-y-1/2 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
103
+ <span className="text-xs">⌘</span>K
104
+ </kbd>
105
+ </Button>
106
+
107
+ <CommandDialog
108
+ open={searchOpen}
109
+ onOpenChange={setSearchOpen}
110
+ title="Search"
111
+ description={dialogDescription}
112
+ >
113
+ <Command>
114
+ <CommandInput placeholder="Type to search…" />
115
+ <CommandList>
116
+ <CommandEmpty>{emptyMessage}</CommandEmpty>
117
+
118
+ {normalizedGroups.map((group, groupIndex) => (
119
+ <div key={group.label}>
120
+ {groupIndex > 0 ? <CommandSeparator /> : null}
121
+ <CommandGroup heading={group.label}>
122
+ {group.items.map(item => (
123
+ <CommandItem
124
+ key={item.key}
125
+ value={item.value}
126
+ onSelect={() => navigate(item.url)}
127
+ >
128
+ {item.icon ? <item.icon className="mr-2 size-4" /> : null}
129
+ <span>{item.label}</span>
130
+ </CommandItem>
131
+ ))}
132
+ </CommandGroup>
133
+ </div>
134
+ ))}
135
+ </CommandList>
136
+ </Command>
137
+ </CommandDialog>
138
+ </>
139
+ )
140
+ }
@@ -0,0 +1,241 @@
1
+ 'use client'
2
+
3
+ import { ChevronRight } from 'lucide-react'
4
+ import Link from 'next/link'
5
+ import { usePathname } from 'next/navigation'
6
+ import { ComponentType, ReactNode } from 'react'
7
+ import {
8
+ Collapsible,
9
+ CollapsibleContent,
10
+ CollapsibleTrigger
11
+ } from '~/components/ui/collapsible'
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuLabel,
17
+ DropdownMenuSeparator,
18
+ DropdownMenuTrigger
19
+ } from '~/components/ui/dropdown-menu'
20
+ import {
21
+ Sidebar,
22
+ SidebarContent,
23
+ SidebarGroup,
24
+ SidebarGroupContent,
25
+ SidebarGroupLabel,
26
+ SidebarHeader,
27
+ SidebarMenu,
28
+ SidebarMenuButton,
29
+ SidebarMenuItem,
30
+ SidebarMenuSub,
31
+ SidebarMenuSubButton,
32
+ SidebarMenuSubItem,
33
+ SidebarRail,
34
+ useSidebar
35
+ } from '~/components/ui/sidebar'
36
+
37
+ type NavigationSidebarProps = {
38
+ header: ReactNode
39
+ groups: SidebarNavGroup[]
40
+ }
41
+
42
+ export type SidebarNavIcon = ComponentType<{ className?: string }>
43
+
44
+ export type SidebarNavSubItem = {
45
+ title: string
46
+ url: string
47
+ exact?: boolean
48
+ }
49
+
50
+ export type SidebarNavItem = {
51
+ title: string
52
+ url: string
53
+ icon?: SidebarNavIcon
54
+ exact?: boolean
55
+ items?: SidebarNavSubItem[]
56
+ }
57
+
58
+ export type SidebarNavGroup = {
59
+ label: string
60
+ items: SidebarNavItem[]
61
+ /** When true, the group is only shown to superadmins. */
62
+ superadminOnly?: boolean
63
+ }
64
+
65
+ export function NavigationSidebar({ header, groups }: NavigationSidebarProps) {
66
+ const pathname = usePathname()
67
+ const { state, isMobile, setOpenMobile } = useSidebar()
68
+ const isCollapsed = state === 'collapsed' && !isMobile
69
+
70
+ const handleLinkClick = () => {
71
+ if (isMobile) {
72
+ setOpenMobile(false)
73
+ }
74
+ }
75
+
76
+ const allItems = groups.flatMap(g => g.items)
77
+
78
+ const isItemActive = (item: { url: string; exact?: boolean }) => {
79
+ if (item.exact) {
80
+ return pathname === item.url
81
+ }
82
+
83
+ if (pathname !== item.url && !pathname.startsWith(`${item.url}/`)) {
84
+ return false
85
+ }
86
+
87
+ // Yield to a more specific sibling that also matches
88
+ const moreSpecificExists = allItems.some(
89
+ other =>
90
+ other.url !== item.url &&
91
+ other.url.length > item.url.length &&
92
+ other.url.startsWith(item.url) &&
93
+ (pathname === other.url || pathname.startsWith(`${other.url}/`))
94
+ )
95
+
96
+ return !moreSpecificExists
97
+ }
98
+
99
+ const isSubItemActive = (
100
+ item: SidebarNavSubItem,
101
+ siblings: SidebarNavSubItem[]
102
+ ) => {
103
+ if (!isItemActive(item)) {
104
+ return false
105
+ }
106
+
107
+ return !siblings.some(
108
+ sibling =>
109
+ sibling.url !== item.url &&
110
+ isItemActive(sibling) &&
111
+ sibling.url.length > item.url.length
112
+ )
113
+ }
114
+
115
+ const isGroupActive = (item: SidebarNavItem) => {
116
+ if (isItemActive(item)) {
117
+ return true
118
+ }
119
+
120
+ return item.items?.some(subItem => isItemActive(subItem)) ?? false
121
+ }
122
+
123
+ return (
124
+ <Sidebar collapsible="icon" variant="sidebar">
125
+ <SidebarHeader>{header}</SidebarHeader>
126
+ <SidebarContent>
127
+ {groups.map(group => (
128
+ <SidebarGroup key={group.label}>
129
+ <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
130
+ <SidebarGroupContent>
131
+ <SidebarMenu>
132
+ {group.items.map(item =>
133
+ item.items ? (
134
+ isCollapsed ? (
135
+ <SidebarMenuItem key={item.title}>
136
+ <DropdownMenu>
137
+ <DropdownMenuTrigger asChild>
138
+ <SidebarMenuButton
139
+ tooltip={item.title}
140
+ isActive={isGroupActive(item)}
141
+ >
142
+ {item.icon ? <item.icon /> : null}
143
+ <span>{item.title}</span>
144
+ <ChevronRight className="ml-auto transition-transform duration-200" />
145
+ </SidebarMenuButton>
146
+ </DropdownMenuTrigger>
147
+ <DropdownMenuContent
148
+ side="right"
149
+ align="start"
150
+ sideOffset={4}
151
+ >
152
+ <DropdownMenuLabel>{item.title}</DropdownMenuLabel>
153
+ <DropdownMenuSeparator />
154
+ {item.items.map(subItem => (
155
+ <DropdownMenuItem
156
+ key={subItem.url}
157
+ asChild
158
+ className={
159
+ isSubItemActive(subItem, item.items!)
160
+ ? 'bg-accent font-medium'
161
+ : ''
162
+ }
163
+ >
164
+ <Link
165
+ href={subItem.url}
166
+ onClick={handleLinkClick}
167
+ >
168
+ {subItem.title}
169
+ </Link>
170
+ </DropdownMenuItem>
171
+ ))}
172
+ </DropdownMenuContent>
173
+ </DropdownMenu>
174
+ </SidebarMenuItem>
175
+ ) : (
176
+ <Collapsible
177
+ key={item.title}
178
+ asChild
179
+ defaultOpen={isGroupActive(item)}
180
+ className="group/collapsible"
181
+ >
182
+ <SidebarMenuItem>
183
+ <CollapsibleTrigger asChild>
184
+ <SidebarMenuButton
185
+ tooltip={item.title}
186
+ isActive={isGroupActive(item)}
187
+ >
188
+ {item.icon ? <item.icon /> : null}
189
+ <span>{item.title}</span>
190
+ <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
191
+ </SidebarMenuButton>
192
+ </CollapsibleTrigger>
193
+ <CollapsibleContent>
194
+ <SidebarMenuSub>
195
+ {item.items.map(subItem => (
196
+ <SidebarMenuSubItem key={subItem.url}>
197
+ <SidebarMenuSubButton
198
+ asChild
199
+ isActive={isSubItemActive(
200
+ subItem,
201
+ item.items!
202
+ )}
203
+ >
204
+ <Link
205
+ href={subItem.url}
206
+ onClick={handleLinkClick}
207
+ >
208
+ <span>{subItem.title}</span>
209
+ </Link>
210
+ </SidebarMenuSubButton>
211
+ </SidebarMenuSubItem>
212
+ ))}
213
+ </SidebarMenuSub>
214
+ </CollapsibleContent>
215
+ </SidebarMenuItem>
216
+ </Collapsible>
217
+ )
218
+ ) : (
219
+ <SidebarMenuItem key={item.title}>
220
+ <SidebarMenuButton
221
+ asChild
222
+ tooltip={item.title}
223
+ isActive={isItemActive(item)}
224
+ >
225
+ <Link href={item.url} onClick={handleLinkClick}>
226
+ {item.icon ? <item.icon /> : null}
227
+ <span>{item.title}</span>
228
+ </Link>
229
+ </SidebarMenuButton>
230
+ </SidebarMenuItem>
231
+ )
232
+ )}
233
+ </SidebarMenu>
234
+ </SidebarGroupContent>
235
+ </SidebarGroup>
236
+ ))}
237
+ </SidebarContent>
238
+ <SidebarRail />
239
+ </Sidebar>
240
+ )
241
+ }
@@ -0,0 +1,38 @@
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
+ */
7
+ import { FC, ReactNode } from 'react'
8
+
9
+ export const PageHeading = ({
10
+ title,
11
+ description,
12
+ icon: Icon,
13
+ children
14
+ }: {
15
+ title: string
16
+ description: string
17
+ icon?: FC<{ className?: string }>
18
+ children?: ReactNode
19
+ }) => {
20
+ return (
21
+ <div className="flex items-center justify-between gap-3">
22
+ <div className="flex items-center gap-3">
23
+ {Icon && (
24
+ <div className="bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
25
+ <Icon className="h-5 w-5" />
26
+ </div>
27
+ )}
28
+ <div>
29
+ <h1 className="text-2xl font-bold tracking-tight">{title}</h1>
30
+ <p className="text-muted-foreground">{description}</p>
31
+ </div>
32
+ </div>
33
+ {children ? (
34
+ <div className="flex items-center gap-2">{children}</div>
35
+ ) : null}
36
+ </div>
37
+ )
38
+ }