create-crm-starter 0.1.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 (261) hide show
  1. package/README.md +53 -0
  2. package/dist/index.js +2341 -0
  3. package/package.json +51 -0
  4. package/template/base/_dot_gitignore +11 -0
  5. package/template/base/eslint.config.mjs +7 -0
  6. package/template/base/next.config.ts +17 -0
  7. package/template/base/package.json +35 -0
  8. package/template/base/postcss.config.mjs +7 -0
  9. package/template/base/scripts/setup.mjs +146 -0
  10. package/template/base/src/app/globals.css +68 -0
  11. package/template/base/src/app/layout.tsx +19 -0
  12. package/template/base/src/app/page.tsx +37 -0
  13. package/template/base/src/components/ui/badge.tsx +29 -0
  14. package/template/base/src/components/ui/button.tsx +49 -0
  15. package/template/base/src/components/ui/card.tsx +54 -0
  16. package/template/base/src/components/ui/input.tsx +19 -0
  17. package/template/base/src/components/ui/label.tsx +19 -0
  18. package/template/base/src/components/ui/separator.tsx +25 -0
  19. package/template/base/src/components/ui/skeleton.tsx +7 -0
  20. package/template/base/src/lib/utils.ts +17 -0
  21. package/template/base/tsconfig.json +21 -0
  22. package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
  23. package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
  24. package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
  25. package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
  26. package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
  27. package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
  28. package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
  29. package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
  30. package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
  31. package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
  32. package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
  33. package/template/extras/auth-better-auth/src/middleware.ts +25 -0
  34. package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
  35. package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
  36. package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
  37. package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
  38. package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
  39. package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
  40. package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
  41. package/template/extras/auth-clerk/src/middleware.ts +17 -0
  42. package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
  43. package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
  44. package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
  45. package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
  46. package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
  47. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
  48. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
  49. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
  50. package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
  51. package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
  52. package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
  53. package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
  54. package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
  55. package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
  56. package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
  57. package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
  58. package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
  59. package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
  60. package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
  61. package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
  62. package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
  63. package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
  64. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
  65. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
  66. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
  67. package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
  68. package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
  69. package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
  70. package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
  71. package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
  72. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
  73. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
  74. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
  75. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
  76. package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
  77. package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
  78. package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
  79. package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
  80. package/template/extras/customers/_fragments/convex.txt +28 -0
  81. package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
  82. package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
  83. package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
  84. package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
  85. package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
  86. package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
  87. package/template/extras/customers/convex/convex/customers.ts +52 -0
  88. package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
  89. package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
  90. package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
  91. package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
  92. package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
  93. package/template/extras/db-convex/convex/_generated/README.md +13 -0
  94. package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
  95. package/template/extras/db-convex/convex/_generated/api.js +12 -0
  96. package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
  97. package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
  98. package/template/extras/db-convex/convex/_generated/server.js +12 -0
  99. package/template/extras/db-convex/convex/auth.config.ts +17 -0
  100. package/template/extras/db-convex/convex/schema.ts +28 -0
  101. package/template/extras/db-convex/src/app/layout.tsx +20 -0
  102. package/template/extras/db-convex/src/components/providers.tsx +28 -0
  103. package/template/extras/db-convex/src/lib/convex.ts +6 -0
  104. package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
  105. package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
  106. package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
  107. package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
  108. package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
  109. package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
  110. package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
  111. package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
  112. package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
  113. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
  114. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
  115. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
  116. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
  117. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
  118. package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
  119. package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
  120. package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
  121. package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
  122. package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
  123. package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
  124. package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
  125. package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
  126. package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
  127. package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
  128. package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
  129. package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
  130. package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
  131. package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
  132. package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
  133. package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
  134. package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
  135. package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
  136. package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
  137. package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
  138. package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
  139. package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
  140. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
  141. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
  142. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
  143. package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
  144. package/template/extras/jobs/_fragments/convex.txt +21 -0
  145. package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
  146. package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
  147. package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
  148. package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
  149. package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
  150. package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
  151. package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
  152. package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
  153. package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
  154. package/template/extras/jobs/convex/convex/jobs.ts +46 -0
  155. package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
  156. package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
  157. package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
  158. package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
  159. package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
  160. package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
  161. package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
  162. package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
  163. package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
  164. package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
  165. package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
  166. package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
  167. package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
  168. package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
  169. package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
  170. package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
  171. package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
  172. package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
  173. package/template/extras/mobile/mobile/README.md +67 -0
  174. package/template/extras/mobile/mobile/_dot_env.example +5 -0
  175. package/template/extras/mobile/mobile/_dot_gitignore +26 -0
  176. package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
  177. package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
  178. package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
  179. package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
  180. package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
  181. package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
  182. package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
  183. package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
  184. package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
  185. package/template/extras/mobile/mobile/app/index.tsx +23 -0
  186. package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
  187. package/template/extras/mobile/mobile/app.brand.ts +14 -0
  188. package/template/extras/mobile/mobile/app.config.ts +40 -0
  189. package/template/extras/mobile/mobile/app.features.ts +11 -0
  190. package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
  191. package/template/extras/mobile/mobile/eas.json +22 -0
  192. package/template/extras/mobile/mobile/lib/api.ts +253 -0
  193. package/template/extras/mobile/mobile/lib/auth.ts +51 -0
  194. package/template/extras/mobile/mobile/lib/format.ts +23 -0
  195. package/template/extras/mobile/mobile/lib/push.ts +24 -0
  196. package/template/extras/mobile/mobile/lib/theme.ts +16 -0
  197. package/template/extras/mobile/mobile/package.json +34 -0
  198. package/template/extras/mobile/mobile/tsconfig.json +11 -0
  199. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
  200. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
  201. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
  202. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
  203. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
  204. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
  205. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
  206. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
  207. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
  208. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
  209. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
  210. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
  211. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
  212. package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
  213. package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
  214. package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
  215. package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
  216. package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
  217. package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
  218. package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
  219. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
  220. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
  221. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
  222. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
  223. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
  224. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
  225. package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
  226. package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
  227. package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
  228. package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
  229. package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
  230. package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
  231. package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
  232. package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
  233. package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
  234. package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
  235. package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
  236. package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
  237. package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
  238. package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
  239. package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
  240. package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
  241. package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
  242. package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
  243. package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
  244. package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
  245. package/template/extras/seed/_shared/scripts/seed.ts +35 -0
  246. package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
  247. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
  248. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
  249. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
  250. package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
  251. package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
  252. package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
  253. package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
  254. package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
  255. package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
  256. package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
  257. package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
  258. package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
  259. package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
  260. package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
  261. package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
@@ -0,0 +1,107 @@
1
+ 'use server';
2
+
3
+ import twilio from 'twilio';
4
+ import { revalidatePath } from 'next/cache';
5
+ import { redirect } from 'next/navigation';
6
+ import { db } from '@/db/client';
7
+ import { smsMessages } from '@/db/schema';
8
+ import { findCustomerByPhone } from './data';
9
+
10
+ export interface SendSmsInput {
11
+ to: string; // E.164 preferred — e.g. +15125550100
12
+ body: string;
13
+ /** Optional explicit customer link (skips phone-based lookup). */
14
+ customerId?: string;
15
+ }
16
+
17
+ // Module-internal helper. NOT exported — files with 'use server' may only
18
+ // export async functions (Next.js Server Actions constraint).
19
+ function normalizeE164(raw: string): string {
20
+ const digits = raw.replace(/[^\d+]/g, '');
21
+ if (digits.startsWith('+')) return digits;
22
+ if (digits.length === 10) return `+1${digits}`;
23
+ if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
24
+ return digits;
25
+ }
26
+
27
+ async function persistOutbound(opts: {
28
+ to: string;
29
+ from: string;
30
+ body: string;
31
+ twilioMessageSid?: string;
32
+ status?: string;
33
+ customerId?: string;
34
+ }) {
35
+ let { customerId } = opts;
36
+ if (!customerId) {
37
+ const match = await findCustomerByPhone(opts.to);
38
+ if (match.customerId) customerId = match.customerId;
39
+ }
40
+ await db.insert(smsMessages).values({
41
+ customerId: customerId ?? null,
42
+ direction: 'outbound',
43
+ fromNumber: opts.from,
44
+ toNumber: opts.to,
45
+ body: opts.body,
46
+ twilioMessageSid: opts.twilioMessageSid ?? null,
47
+ status: opts.status ?? 'queued',
48
+ });
49
+ }
50
+
51
+ export async function sendSms(formData: FormData): Promise<void> {
52
+ const accountSid = process.env.TWILIO_ACCOUNT_SID;
53
+ const authToken = process.env.TWILIO_AUTH_TOKEN;
54
+ const from = process.env.TWILIO_PHONE_NUMBER;
55
+ if (!accountSid || !authToken || !from) {
56
+ throw new Error(
57
+ 'Twilio not configured. Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER in .env.local.',
58
+ );
59
+ }
60
+ if (!accountSid.startsWith('AC')) {
61
+ throw new Error('TWILIO_ACCOUNT_SID does not look right — it should start with "AC".');
62
+ }
63
+
64
+ const toRaw = (formData.get('to') ?? '').toString().trim();
65
+ const body = (formData.get('body') ?? '').toString().trim();
66
+ const explicitCustomer = (formData.get('customerId') ?? '').toString().trim() || undefined;
67
+ if (!toRaw || !body) throw new Error('Both "to" and "body" are required.');
68
+
69
+ const to = normalizeE164(toRaw);
70
+ const client = twilio(accountSid, authToken);
71
+ const msg = await client.messages.create({ to, from, body });
72
+
73
+ await persistOutbound({
74
+ to, from, body,
75
+ twilioMessageSid: msg.sid,
76
+ status: msg.status,
77
+ customerId: explicitCustomer,
78
+ });
79
+
80
+ revalidatePath('/sms');
81
+ if (explicitCustomer) {
82
+ revalidatePath(`/sms/${explicitCustomer}`);
83
+ redirect(`/sms/${explicitCustomer}?sent=true`);
84
+ }
85
+ redirect('/sms?sent=true');
86
+ }
87
+
88
+ /**
89
+ * Server-callable variant used by automations (e.g. job "On My Way" trigger).
90
+ */
91
+ export async function sendSmsRaw(input: SendSmsInput): Promise<void> {
92
+ const accountSid = process.env.TWILIO_ACCOUNT_SID;
93
+ const authToken = process.env.TWILIO_AUTH_TOKEN;
94
+ const from = process.env.TWILIO_PHONE_NUMBER;
95
+ if (!accountSid || !authToken || !from) {
96
+ throw new Error('Twilio not configured');
97
+ }
98
+ const client = twilio(accountSid, authToken);
99
+ const to = normalizeE164(input.to);
100
+ const msg = await client.messages.create({ to, from, body: input.body });
101
+ await persistOutbound({
102
+ to, from, body: input.body,
103
+ twilioMessageSid: msg.sid,
104
+ status: msg.status,
105
+ customerId: input.customerId,
106
+ });
107
+ }
@@ -0,0 +1,111 @@
1
+ import { and, desc, eq, isNotNull, sql } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import { customers, smsMessages } from '@/db/schema';
4
+
5
+ export interface SmsThread {
6
+ customerId: string;
7
+ customerName: string;
8
+ customerPhone: string;
9
+ lastBody: string;
10
+ lastAt: string; // ISO
11
+ unread: number; // placeholder until read-tracking is added
12
+ }
13
+
14
+ export interface SmsMessage {
15
+ id: string;
16
+ customerId: string | null;
17
+ direction: 'inbound' | 'outbound';
18
+ fromNumber: string;
19
+ toNumber: string;
20
+ body: string;
21
+ status: string | null;
22
+ createdAt: string;
23
+ }
24
+
25
+ /**
26
+ * One row per customer who's exchanged messages, with the latest message
27
+ * preview. Customers without messages are filtered out via the JOIN.
28
+ */
29
+ export async function getSmsThreads(): Promise<SmsThread[]> {
30
+ // We want the latest message per customer. Postgres-DISTINCT-ON style
31
+ // via a subquery: rank by createdAt, then keep rank=1.
32
+ const rows = await db
33
+ .select({
34
+ customerId: smsMessages.customerId,
35
+ customerName: customers.name,
36
+ customerPhones: customers.phones,
37
+ body: smsMessages.body,
38
+ createdAt: smsMessages.createdAt,
39
+ rowNum: sql<number>`ROW_NUMBER() OVER (PARTITION BY ${smsMessages.customerId} ORDER BY ${smsMessages.createdAt} DESC)`.as('rn'),
40
+ })
41
+ .from(smsMessages)
42
+ .leftJoin(customers, eq(smsMessages.customerId, customers.id))
43
+ .where(isNotNull(smsMessages.customerId))
44
+ .orderBy(desc(smsMessages.createdAt));
45
+
46
+ const threads: SmsThread[] = [];
47
+ const seen = new Set<string>();
48
+ for (const r of rows) {
49
+ if (!r.customerId || seen.has(r.customerId)) continue;
50
+ seen.add(r.customerId);
51
+ const phones = (r.customerPhones as string[]) ?? [];
52
+ threads.push({
53
+ customerId: r.customerId,
54
+ customerName: r.customerName ?? '(deleted)',
55
+ customerPhone: phones[0] ?? '',
56
+ lastBody: r.body,
57
+ lastAt: r.createdAt.toISOString(),
58
+ unread: 0,
59
+ });
60
+ }
61
+ return threads;
62
+ }
63
+
64
+ export async function getMessagesForCustomer(customerId: string): Promise<SmsMessage[]> {
65
+ const rows = await db
66
+ .select()
67
+ .from(smsMessages)
68
+ .where(eq(smsMessages.customerId, customerId))
69
+ .orderBy(smsMessages.createdAt);
70
+ return rows.map((m) => ({
71
+ id: m.id,
72
+ customerId: m.customerId,
73
+ direction: m.direction as 'inbound' | 'outbound',
74
+ fromNumber: m.fromNumber,
75
+ toNumber: m.toNumber,
76
+ body: m.body,
77
+ status: m.status,
78
+ createdAt: m.createdAt.toISOString(),
79
+ }));
80
+ }
81
+
82
+ export interface InboundMessageMatch {
83
+ customerId: string | null;
84
+ customerName: string | null;
85
+ }
86
+
87
+ /**
88
+ * Looks up a customer by phone number, normalizing both sides for a
89
+ * best-effort match. Twilio gives us E.164 (+15125550100); customers
90
+ * are stored as free-form so we normalize to last-10-digits comparison.
91
+ */
92
+ export async function findCustomerByPhone(twilioFrom: string): Promise<InboundMessageMatch> {
93
+ const target = twilioFrom.replace(/[^\d]/g, '').slice(-10);
94
+ if (target.length < 7) return { customerId: null, customerName: null };
95
+
96
+ // Crude scan — for v1 with <10k customers this is fine. For scale,
97
+ // add a normalized_phone column + index.
98
+ const rows = await db
99
+ .select({ id: customers.id, name: customers.name, phones: customers.phones })
100
+ .from(customers);
101
+
102
+ for (const c of rows) {
103
+ for (const p of (c.phones as string[]) ?? []) {
104
+ const norm = p.replace(/[^\d]/g, '').slice(-10);
105
+ if (norm === target) {
106
+ return { customerId: c.id, customerName: c.name };
107
+ }
108
+ }
109
+ }
110
+ return { customerId: null, customerName: null };
111
+ }
@@ -0,0 +1,51 @@
1
+ import Link from 'next/link';
2
+ import { business } from '@/lib/business';
3
+
4
+ interface LayoutProps {
5
+ children: React.ReactNode;
6
+ params: Promise<{ token: string }>;
7
+ }
8
+
9
+ export default async function PortalLayout({ children, params }: LayoutProps) {
10
+ const { token } = await params;
11
+ return (
12
+ <div className="bg-muted/30 min-h-screen">
13
+ <header className="bg-background border-border border-b">
14
+ <div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-5">
15
+ <div className="flex items-center gap-3">
16
+ {business.brand.logoSrc && (
17
+ // eslint-disable-next-line @next/next/no-img-element
18
+ <img src={business.brand.logoSrc} alt={business.name} className="h-9 w-auto" />
19
+ )}
20
+ <div>
21
+ <h1 className="text-lg font-bold">{business.name}</h1>
22
+ <p className="text-muted-foreground text-xs">Customer portal</p>
23
+ </div>
24
+ </div>
25
+ {business.phone && (
26
+ <a href={`tel:${business.phone}`} className="text-muted-foreground hover:text-foreground text-sm">
27
+ {business.phone}
28
+ </a>
29
+ )}
30
+ </div>
31
+ <nav className="bg-background border-border border-b">
32
+ <div className="mx-auto flex max-w-4xl gap-4 overflow-x-auto px-6 py-2 text-sm">
33
+ <PortalNavLink href={`/portal/${token}`}>Overview</PortalNavLink>
34
+ <PortalNavLink href={`/portal/${token}/invoices`}>Invoices</PortalNavLink>
35
+ <PortalNavLink href={`/portal/${token}/estimates`}>Estimates</PortalNavLink>
36
+ <PortalNavLink href={`/portal/${token}/appointments`}>Appointments</PortalNavLink>
37
+ </div>
38
+ </nav>
39
+ </header>
40
+ <main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function PortalNavLink({ href, children }: { href: string; children: React.ReactNode }) {
46
+ return (
47
+ <Link href={href} className="text-muted-foreground hover:text-foreground whitespace-nowrap py-1">
48
+ {children}
49
+ </Link>
50
+ );
51
+ }
@@ -0,0 +1,78 @@
1
+ import { notFound } from 'next/navigation';
2
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
3
+ import { getPortalData } from '@/lib/portal/data';
4
+ import { JOB_STATUS_COLOR, JOB_STATUS_LABEL } from '@/lib/jobs/types';
5
+ import { formatCurrency, formatDate } from '@/lib/utils';
6
+
7
+ interface PageProps { params: Promise<{ token: string }>; }
8
+
9
+ export default async function PortalAppointmentsPage({ params }: PageProps) {
10
+ const { token } = await params;
11
+ const data = await getPortalData(token);
12
+ if (!data) notFound();
13
+
14
+ const upcoming = data.jobs.filter((j) => j.scheduledAt && new Date(j.scheduledAt) >= new Date());
15
+ const past = data.jobs.filter((j) => !j.scheduledAt || new Date(j.scheduledAt) < new Date());
16
+
17
+ return (
18
+ <div className="space-y-6">
19
+ <h2 className="text-2xl font-bold">Your appointments</h2>
20
+
21
+ <Card>
22
+ <CardHeader><CardTitle className="text-base">Upcoming ({upcoming.length})</CardTitle></CardHeader>
23
+ <CardContent className="space-y-2">
24
+ {upcoming.length === 0 ? (
25
+ <p className="text-muted-foreground text-sm">No upcoming appointments scheduled.</p>
26
+ ) : (
27
+ upcoming.map((j) => (
28
+ <div key={j.id} className="flex items-center justify-between border-b py-2 last:border-b-0">
29
+ <div>
30
+ <div className="font-medium">{j.serviceType}</div>
31
+ <div className="text-muted-foreground text-xs">
32
+ {j.scheduledAt && formatDate(j.scheduledAt, { dateStyle: 'full', timeStyle: 'short' })}
33
+ {j.arrivalWindow && ` · arrival ${j.arrivalWindow}`}
34
+ </div>
35
+ </div>
36
+ <span className={`rounded-md px-2 py-1 text-xs font-medium ${JOB_STATUS_COLOR[j.status]}`}>
37
+ {JOB_STATUS_LABEL[j.status]}
38
+ </span>
39
+ </div>
40
+ ))
41
+ )}
42
+ </CardContent>
43
+ </Card>
44
+
45
+ <Card>
46
+ <CardHeader><CardTitle className="text-base">History ({past.length})</CardTitle></CardHeader>
47
+ <CardContent className="p-0">
48
+ {past.length === 0 ? (
49
+ <p className="text-muted-foreground p-4 text-sm">No past appointments.</p>
50
+ ) : (
51
+ <table className="w-full text-sm">
52
+ <tbody className="divide-border divide-y">
53
+ {past.slice(0, 20).map((j) => (
54
+ <tr key={j.id}>
55
+ <td className="px-4 py-3">
56
+ <div className="font-medium">{j.serviceType}</div>
57
+ <div className="text-muted-foreground text-xs">
58
+ {j.scheduledAt ? formatDate(j.scheduledAt) : formatDate(j.createdAt)}
59
+ </div>
60
+ </td>
61
+ <td className="px-4 py-3">
62
+ <span className={`rounded-md px-2 py-1 text-xs font-medium ${JOB_STATUS_COLOR[j.status]}`}>
63
+ {JOB_STATUS_LABEL[j.status]}
64
+ </span>
65
+ </td>
66
+ <td className="text-muted-foreground px-4 py-3 text-right text-xs">
67
+ {j.total > 0 ? formatCurrency(j.total) : ''}
68
+ </td>
69
+ </tr>
70
+ ))}
71
+ </tbody>
72
+ </table>
73
+ )}
74
+ </CardContent>
75
+ </Card>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,94 @@
1
+ import { notFound } from 'next/navigation';
2
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
3
+ import { Badge } from '@/components/ui/badge';
4
+ import { getPortalData } from '@/lib/portal/data';
5
+ import { markEstimateViewed } from '@/lib/portal/actions';
6
+ import { ESTIMATE_STATUS_LABEL, ESTIMATE_STATUS_VARIANT } from '@/lib/estimates/types';
7
+ import { EstimateApproval } from '@/components/portal/estimate-approval';
8
+ import { formatCurrency, formatDate } from '@/lib/utils';
9
+
10
+ interface PageProps { params: Promise<{ token: string }>; }
11
+
12
+ export default async function PortalEstimatesPage({ params }: PageProps) {
13
+ const { token } = await params;
14
+ const data = await getPortalData(token);
15
+ if (!data) notFound();
16
+
17
+ // Bump any 'sent' estimates to 'viewed' on first portal load.
18
+ await Promise.all(
19
+ data.estimates
20
+ .filter((e) => e.status === 'sent')
21
+ .map((e) => markEstimateViewed(token, e.id)),
22
+ );
23
+
24
+ const actionable = new Set(['sent', 'viewed', 'draft']);
25
+
26
+ return (
27
+ <div className="space-y-6">
28
+ <h2 className="text-2xl font-bold">Your estimates</h2>
29
+ {data.estimates.length === 0 ? (
30
+ <Card><CardContent className="p-6 text-muted-foreground text-sm">No estimates yet.</CardContent></Card>
31
+ ) : (
32
+ data.estimates.map((e) => (
33
+ <Card key={e.id}>
34
+ <CardHeader className="flex flex-row items-center justify-between space-y-0">
35
+ <div>
36
+ <CardTitle className="text-base">Estimate #{e.id.slice(-6)}</CardTitle>
37
+ <p className="text-muted-foreground mt-1 text-xs">
38
+ {e.sentAt ? `Sent ${formatDate(e.sentAt)}` : 'Draft'}
39
+ {e.validUntil && ` · valid until ${formatDate(e.validUntil)}`}
40
+ {e.approvedAt && ` · approved ${formatDate(e.approvedAt)}`}
41
+ </p>
42
+ </div>
43
+ <div className="flex items-center gap-3">
44
+ <Badge variant={ESTIMATE_STATUS_VARIANT[e.status]}>{ESTIMATE_STATUS_LABEL[e.status]}</Badge>
45
+ <span className="text-xl font-bold">{formatCurrency(e.total)}</span>
46
+ </div>
47
+ </CardHeader>
48
+ <CardContent className="p-0">
49
+ <table className="w-full text-sm">
50
+ <thead className="text-muted-foreground bg-muted/30 text-xs uppercase">
51
+ <tr>
52
+ <th className="px-4 py-2 text-left font-medium">Item</th>
53
+ <th className="px-4 py-2 text-right font-medium">Qty</th>
54
+ <th className="px-4 py-2 text-right font-medium">Subtotal</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody className="divide-border divide-y">
58
+ {e.lineItems.map((li, i) => (
59
+ <tr key={i}>
60
+ <td className="px-4 py-2">{li.description}</td>
61
+ <td className="px-4 py-2 text-right">{li.qty}</td>
62
+ <td className="px-4 py-2 text-right font-medium">{formatCurrency(li.qty * li.unitPrice)}</td>
63
+ </tr>
64
+ ))}
65
+ </tbody>
66
+ </table>
67
+ {e.notes && (
68
+ <div className="border-t p-4 text-sm">
69
+ <div className="text-muted-foreground mb-1 text-xs uppercase">Notes</div>
70
+ <div className="whitespace-pre-wrap">{e.notes}</div>
71
+ </div>
72
+ )}
73
+ {actionable.has(e.status) && (
74
+ <div className="p-4">
75
+ <EstimateApproval portalToken={token} estimateId={e.id} total={e.total} />
76
+ </div>
77
+ )}
78
+ {e.status === 'approved' && (
79
+ <div className="border-t bg-emerald-50 text-emerald-900 px-4 py-3 text-sm">
80
+ ✓ Approved — we&apos;ll be in touch to schedule.
81
+ </div>
82
+ )}
83
+ {e.status === 'declined' && (
84
+ <div className="border-t bg-muted px-4 py-3 text-sm text-muted-foreground">
85
+ Declined. If you change your mind, give us a call.
86
+ </div>
87
+ )}
88
+ </CardContent>
89
+ </Card>
90
+ ))
91
+ )}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,71 @@
1
+ import Link from 'next/link';
2
+ import { notFound } from 'next/navigation';
3
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { Button } from '@/components/ui/button';
6
+ import { getInvoicePublicToken, getPortalData } from '@/lib/portal/data';
7
+ import { INVOICE_STATUS_LABEL, INVOICE_STATUS_VARIANT } from '@/lib/invoices/types';
8
+ import { formatCurrency, formatDate } from '@/lib/utils';
9
+
10
+ interface PageProps { params: Promise<{ token: string }>; }
11
+
12
+ export default async function PortalInvoicesPage({ params }: PageProps) {
13
+ const { token } = await params;
14
+ const data = await getPortalData(token);
15
+ if (!data) notFound();
16
+
17
+ // Resolve per-invoice public tokens so each "Pay" / "View" link goes to
18
+ // the existing /i/[invoiceToken] page (consistent UX with email links).
19
+ const withTokens = await Promise.all(
20
+ data.invoices.map(async (i) => ({ ...i, publicToken: await getInvoicePublicToken(i.id) })),
21
+ );
22
+
23
+ return (
24
+ <div className="space-y-6">
25
+ <h2 className="text-2xl font-bold">Your invoices</h2>
26
+ {withTokens.length === 0 ? (
27
+ <Card><CardContent className="p-6 text-muted-foreground text-sm">No invoices yet.</CardContent></Card>
28
+ ) : (
29
+ <Card>
30
+ <CardHeader><CardTitle className="text-base">{withTokens.length} total</CardTitle></CardHeader>
31
+ <CardContent className="p-0">
32
+ <table className="w-full text-sm">
33
+ <thead className="text-muted-foreground bg-muted/50 text-xs uppercase">
34
+ <tr>
35
+ <th className="px-4 py-3 text-left font-medium">Invoice #</th>
36
+ <th className="px-4 py-3 text-left font-medium">Status</th>
37
+ <th className="px-4 py-3 text-left font-medium">Due</th>
38
+ <th className="px-4 py-3 text-right font-medium">Total</th>
39
+ <th className="px-4 py-3 text-right font-medium">Action</th>
40
+ </tr>
41
+ </thead>
42
+ <tbody className="divide-border divide-y">
43
+ {withTokens.map((i) => (
44
+ <tr key={i.id}>
45
+ <td className="px-4 py-3 font-mono text-xs">{i.invoiceNumber}</td>
46
+ <td className="px-4 py-3">
47
+ <Badge variant={INVOICE_STATUS_VARIANT[i.status]}>{INVOICE_STATUS_LABEL[i.status]}</Badge>
48
+ </td>
49
+ <td className="text-muted-foreground px-4 py-3">
50
+ {i.dueDate ? formatDate(i.dueDate) : '—'}
51
+ </td>
52
+ <td className="px-4 py-3 text-right font-medium">{formatCurrency(i.total)}</td>
53
+ <td className="px-4 py-3 text-right">
54
+ {i.publicToken && (
55
+ <Button asChild size="sm" variant={i.amountDue > 0 ? 'default' : 'outline'}>
56
+ <Link href={`/i/${i.publicToken}`}>
57
+ {i.amountDue > 0 ? `Pay ${formatCurrency(i.amountDue)}` : 'View'}
58
+ </Link>
59
+ </Button>
60
+ )}
61
+ </td>
62
+ </tr>
63
+ ))}
64
+ </tbody>
65
+ </table>
66
+ </CardContent>
67
+ </Card>
68
+ )}
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,118 @@
1
+ import Link from 'next/link';
2
+ import { notFound } from 'next/navigation';
3
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { getPortalData } from '@/lib/portal/data';
6
+ import { INVOICE_STATUS_LABEL, INVOICE_STATUS_VARIANT } from '@/lib/invoices/types';
7
+ import { ESTIMATE_STATUS_LABEL, ESTIMATE_STATUS_VARIANT } from '@/lib/estimates/types';
8
+ import { JOB_STATUS_COLOR, JOB_STATUS_LABEL } from '@/lib/jobs/types';
9
+ import { formatCurrency, formatDate } from '@/lib/utils';
10
+
11
+ interface PageProps { params: Promise<{ token: string }>; }
12
+
13
+ export default async function PortalOverviewPage({ params }: PageProps) {
14
+ const { token } = await params;
15
+ const data = await getPortalData(token);
16
+ if (!data) notFound();
17
+
18
+ const openInvoices = data.invoices.filter((i) => i.amountDue > 0).slice(0, 5);
19
+ const recentEstimates = data.estimates.slice(0, 3);
20
+ const upcomingJobs = data.jobs
21
+ .filter((j) => j.scheduledAt && new Date(j.scheduledAt) >= new Date())
22
+ .slice(0, 3);
23
+
24
+ return (
25
+ <div className="space-y-6">
26
+ <div>
27
+ <p className="text-muted-foreground text-sm">Welcome back</p>
28
+ <h2 className="text-3xl font-bold">{data.customer.name}</h2>
29
+ </div>
30
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
31
+ <Card>
32
+ <CardHeader><CardTitle className="text-base">Balance due</CardTitle></CardHeader>
33
+ <CardContent>
34
+ <div className={`text-3xl font-bold ${data.totals.balanceDue > 0 ? 'text-destructive' : ''}`}>
35
+ {formatCurrency(data.totals.balanceDue)}
36
+ </div>
37
+ {data.totals.balanceDue > 0 && (
38
+ <Link href={`/portal/${token}/invoices`} className="text-brand text-sm underline mt-2 inline-block">
39
+ Pay invoices →
40
+ </Link>
41
+ )}
42
+ </CardContent>
43
+ </Card>
44
+ <Card>
45
+ <CardHeader><CardTitle className="text-base">Lifetime spend with us</CardTitle></CardHeader>
46
+ <CardContent className="text-3xl font-bold text-emerald-600">
47
+ {formatCurrency(data.totals.lifetimeValue)}
48
+ </CardContent>
49
+ </Card>
50
+ </div>
51
+ {openInvoices.length > 0 && (
52
+ <Card>
53
+ <CardHeader><CardTitle>Invoices to pay</CardTitle></CardHeader>
54
+ <CardContent className="p-0">
55
+ <table className="w-full text-sm">
56
+ <tbody className="divide-border divide-y">
57
+ {openInvoices.map((i) => (
58
+ <tr key={i.id}>
59
+ <td className="px-4 py-3 font-mono text-xs">{i.invoiceNumber}</td>
60
+ <td className="px-4 py-3">
61
+ <Badge variant={INVOICE_STATUS_VARIANT[i.status]}>{INVOICE_STATUS_LABEL[i.status]}</Badge>
62
+ </td>
63
+ <td className="text-muted-foreground px-4 py-3 text-xs">
64
+ {i.dueDate ? `Due ${formatDate(i.dueDate)}` : ''}
65
+ </td>
66
+ <td className="px-4 py-3 text-right font-bold">{formatCurrency(i.amountDue)}</td>
67
+ </tr>
68
+ ))}
69
+ </tbody>
70
+ </table>
71
+ </CardContent>
72
+ </Card>
73
+ )}
74
+ {upcomingJobs.length > 0 && (
75
+ <Card>
76
+ <CardHeader><CardTitle>Upcoming appointments</CardTitle></CardHeader>
77
+ <CardContent className="space-y-2">
78
+ {upcomingJobs.map((j) => (
79
+ <div key={j.id} className="flex items-center justify-between border-b py-2 last:border-b-0">
80
+ <div>
81
+ <div className="font-medium">{j.serviceType}</div>
82
+ <div className="text-muted-foreground text-xs">
83
+ {j.scheduledAt && formatDate(j.scheduledAt, { dateStyle: 'medium', timeStyle: 'short' })}
84
+ {j.arrivalWindow && ` · ${j.arrivalWindow}`}
85
+ </div>
86
+ </div>
87
+ <span className={`rounded-md px-2 py-1 text-xs font-medium ${JOB_STATUS_COLOR[j.status]}`}>
88
+ {JOB_STATUS_LABEL[j.status]}
89
+ </span>
90
+ </div>
91
+ ))}
92
+ </CardContent>
93
+ </Card>
94
+ )}
95
+ {recentEstimates.length > 0 && (
96
+ <Card>
97
+ <CardHeader><CardTitle>Recent estimates</CardTitle></CardHeader>
98
+ <CardContent className="space-y-2">
99
+ {recentEstimates.map((e) => (
100
+ <div key={e.id} className="flex items-center justify-between border-b py-2 last:border-b-0">
101
+ <div>
102
+ <div className="font-medium">#{e.id.slice(-6)}</div>
103
+ <div className="text-muted-foreground text-xs">
104
+ {e.sentAt ? `Sent ${formatDate(e.sentAt)}` : 'Draft'}
105
+ </div>
106
+ </div>
107
+ <div className="flex items-center gap-3">
108
+ <Badge variant={ESTIMATE_STATUS_VARIANT[e.status]}>{ESTIMATE_STATUS_LABEL[e.status]}</Badge>
109
+ <span className="font-medium">{formatCurrency(e.total)}</span>
110
+ </div>
111
+ </div>
112
+ ))}
113
+ </CardContent>
114
+ </Card>
115
+ )}
116
+ </div>
117
+ );
118
+ }