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
+ }
@@ -0,0 +1,254 @@
1
+ 'use client'
2
+
3
+ import { File, FileText, Loader2, Music, Upload, Video, X } from 'lucide-react'
4
+ import Image from 'next/image'
5
+ import { useRef, useState } from 'react'
6
+ import { generateUploadUrlAction } from '~/app/actions/uploads'
7
+ import { Button } from '~/components/ui/button'
8
+ import { cn } from '~/lib/cn'
9
+ import {
10
+ MAX_FILE_SIZE_BYTES,
11
+ MAX_FILE_SIZE_MB,
12
+ R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
13
+ type R2UploadMimeType
14
+ } from '~/lib/constants'
15
+ import { toastErrorMessage, toastIfActionFailed } from '~/lib/toast-message'
16
+
17
+ function isAllowedMimeType(type: string): type is R2UploadMimeType {
18
+ return type in R2_UPLOAD_EXTENSION_BY_MIME_TYPE
19
+ }
20
+
21
+ function readFileAsDataUrl(file: File): Promise<string> {
22
+ return new Promise((resolve, reject) => {
23
+ const reader = new FileReader()
24
+ reader.onload = () => {
25
+ if (typeof reader.result === 'string') {
26
+ resolve(reader.result)
27
+ return
28
+ }
29
+ reject(new Error('Failed to read file'))
30
+ }
31
+ reader.onerror = () => {
32
+ reject(reader.error ?? new Error('Failed to read file'))
33
+ }
34
+ reader.readAsDataURL(file)
35
+ })
36
+ }
37
+
38
+ // Discriminated union so the preview renderer knows exactly what to show
39
+ // without guessing from a raw URL string.
40
+ type PreviewState =
41
+ | { kind: 'image'; url: string }
42
+ | { kind: 'file'; name: string; mimeType: string }
43
+ | null
44
+
45
+ function getInitialPreview(currentUrl?: string | null): PreviewState {
46
+ if (!currentUrl) return null
47
+ // base64 data URLs and image paths/URLs both render as images
48
+ return { kind: 'image', url: currentUrl }
49
+ }
50
+
51
+ function FileTypeIcon({ mimeType }: { mimeType: string }) {
52
+ if (mimeType.startsWith('video/'))
53
+ return <Video className="text-muted-foreground h-8 w-8" />
54
+ if (mimeType.startsWith('audio/'))
55
+ return <Music className="text-muted-foreground h-8 w-8" />
56
+ if (mimeType === 'application/pdf' || mimeType.includes('text'))
57
+ return <FileText className="text-muted-foreground h-8 w-8" />
58
+ return <File className="text-muted-foreground h-8 w-8" />
59
+ }
60
+
61
+ type Props = {
62
+ /** MIME type filter passed to the file input, e.g. `"image/*"` or `"image/*,application/pdf"`. */
63
+ accept: string
64
+ /** Organization that owns the upload; the server checks you manage it. */
65
+ organizationId: string
66
+ /**
67
+ * Called with the R2 object key (prod) or base64 data URL (dev mode) once
68
+ * the upload finishes. Called with `null` when the user clears the value.
69
+ */
70
+ onClientUploadFinish: (key: string | null) => void
71
+ /**
72
+ * Pre-resolved URL for the current value. Pass the output of
73
+ * `resolveImageUrl` from the parent server component so the preview renders
74
+ * correctly for existing R2 keys.
75
+ */
76
+ currentUrl?: string | null
77
+ className?: string
78
+ /** Passed to next/image `sizes`. It should match the rendered widget width. */
79
+ sizes?: string
80
+ }
81
+
82
+ export function FileUpload({
83
+ accept,
84
+ organizationId,
85
+ onClientUploadFinish,
86
+ currentUrl,
87
+ className,
88
+ sizes = '128px'
89
+ }: Props) {
90
+ const inputRef = useRef<HTMLInputElement>(null)
91
+ const [preview, setPreview] = useState<PreviewState>(
92
+ getInitialPreview(currentUrl)
93
+ )
94
+ const [isUploading, setIsUploading] = useState(false)
95
+
96
+ const handleFile = async (file: File) => {
97
+ if (file.size > MAX_FILE_SIZE_BYTES) {
98
+ toastErrorMessage(`Choose a file under ${MAX_FILE_SIZE_MB} MB.`)
99
+ return
100
+ }
101
+
102
+ if (!isAllowedMimeType(file.type)) {
103
+ toastErrorMessage('Choose a JPEG, PNG, WebP, or GIF image.')
104
+ return
105
+ }
106
+
107
+ setIsUploading(true)
108
+
109
+ // Set the preview from the file type without a network round trip.
110
+ if (file.type.startsWith('image/')) {
111
+ setPreview({ kind: 'image', url: URL.createObjectURL(file) })
112
+ } else {
113
+ setPreview({ kind: 'file', name: file.name, mimeType: file.type })
114
+ }
115
+
116
+ const result = await generateUploadUrlAction({
117
+ filename: file.name,
118
+ mimeType: file.type,
119
+ size: file.size,
120
+ organizationId
121
+ })
122
+
123
+ if (toastIfActionFailed(result)) {
124
+ setPreview(getInitialPreview(currentUrl))
125
+ setIsUploading(false)
126
+ return
127
+ }
128
+ if (!result?.data) {
129
+ toastErrorMessage('The upload could not start. Try again.')
130
+ setPreview(getInitialPreview(currentUrl))
131
+ setIsUploading(false)
132
+ return
133
+ }
134
+
135
+ const { data } = result
136
+
137
+ try {
138
+ if (data.mode === 'r2') {
139
+ const res = await fetch(data.uploadUrl, {
140
+ method: 'PUT',
141
+ body: file,
142
+ headers: { 'Content-Type': file.type }
143
+ })
144
+ if (!res.ok) throw new Error(`R2 upload failed: ${res.status}`)
145
+ onClientUploadFinish(data.key)
146
+ } else {
147
+ // Development mode has no R2 credentials, so store base64 data directly.
148
+ // Await the read so isUploading stays true until onClientUploadFinish runs.
149
+ const base64 = await readFileAsDataUrl(file)
150
+ if (file.type.startsWith('image/')) {
151
+ setPreview({ kind: 'image', url: base64 })
152
+ }
153
+ onClientUploadFinish(base64)
154
+ }
155
+ } catch {
156
+ toastErrorMessage(
157
+ data.mode === 'r2'
158
+ ? 'The upload failed. Check your connection and try again.'
159
+ : 'The file could not be read. Choose it again.'
160
+ )
161
+ setPreview(getInitialPreview(currentUrl))
162
+ } finally {
163
+ setIsUploading(false)
164
+ }
165
+ }
166
+
167
+ const handleClear = (e: React.MouseEvent) => {
168
+ e.stopPropagation()
169
+ setPreview(null)
170
+ onClientUploadFinish(null)
171
+ if (inputRef.current) inputRef.current.value = ''
172
+ }
173
+
174
+ return (
175
+ <div className={cn('relative', className)}>
176
+ <input
177
+ ref={inputRef}
178
+ type="file"
179
+ accept={accept}
180
+ className="hidden"
181
+ onChange={e => {
182
+ const file = e.target.files?.[0]
183
+ if (file) handleFile(file)
184
+ e.target.value = ''
185
+ }}
186
+ />
187
+
188
+ <button
189
+ type="button"
190
+ onClick={() => !isUploading && inputRef.current?.click()}
191
+ disabled={isUploading}
192
+ className={cn(
193
+ 'border-border bg-muted/30 hover:bg-muted/50 relative flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-dashed transition-colors',
194
+ isUploading && 'cursor-not-allowed'
195
+ )}
196
+ >
197
+ {preview?.kind === 'image' && (
198
+ <Image
199
+ src={preview.url}
200
+ alt="Upload preview"
201
+ fill
202
+ sizes={sizes}
203
+ className="object-contain"
204
+ unoptimized={
205
+ preview.url.startsWith('data:') || preview.url.startsWith('blob:')
206
+ }
207
+ />
208
+ )}
209
+
210
+ {preview?.kind === 'file' && (
211
+ <div className="flex flex-col items-center gap-2 px-4">
212
+ <FileTypeIcon mimeType={preview.mimeType} />
213
+ <span className="text-muted-foreground max-w-full truncate text-xs">
214
+ {preview.name}
215
+ </span>
216
+ </div>
217
+ )}
218
+
219
+ {!preview && (
220
+ <div className="flex flex-col items-center gap-2">
221
+ <div className="bg-muted rounded-md p-2">
222
+ <Upload className="text-muted-foreground h-5 w-5" />
223
+ </div>
224
+ <span className="text-muted-foreground text-xs font-medium">
225
+ Click to upload
226
+ </span>
227
+ <span className="text-muted-foreground/60 text-[11px]">
228
+ Max {MAX_FILE_SIZE_MB} MB
229
+ </span>
230
+ </div>
231
+ )}
232
+
233
+ {isUploading && (
234
+ <div className="bg-background/70 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
235
+ <Loader2 className="text-primary h-6 w-6 animate-spin" />
236
+ </div>
237
+ )}
238
+ </button>
239
+
240
+ {preview && !isUploading && (
241
+ <Button
242
+ type="button"
243
+ variant="destructive"
244
+ size="icon"
245
+ className="absolute -top-2 -right-2 h-6 w-6 rounded-full shadow-sm"
246
+ onClick={handleClear}
247
+ aria-label="Remove file"
248
+ >
249
+ <X className="h-3 w-3" />
250
+ </Button>
251
+ )}
252
+ </div>
253
+ )
254
+ }
@@ -0,0 +1,41 @@
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
+
9
+ 'use client'
10
+
11
+ import NextTopLoader from 'nextjs-toploader'
12
+
13
+ import { FC } from 'react'
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
+ */
23
+ export const Loader: FC = () => {
24
+ return (
25
+ <NextTopLoader
26
+ color="var(--primary)"
27
+ initialPosition={0.08}
28
+ crawlSpeed={200}
29
+ height={3}
30
+ crawl={true}
31
+ showSpinner={true}
32
+ easing="ease"
33
+ speed={200}
34
+ shadow="0 0 10px var(--primary), 0 0 5px var(--primary)"
35
+ template='<div class="bar" role="bar"><div class="peg"></div></div>
36
+ <div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'
37
+ zIndex={10000}
38
+ showAtBottom={false}
39
+ />
40
+ )
41
+ }
@@ -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
+ }