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,314 @@
1
+ /**
2
+ * Real Drizzle seed — populates Postgres with realistic demo data using
3
+ * @faker-js/faker. Deterministic (faker.seed(42)) so repeat runs produce
4
+ * stable data for screenshots. Truncates business tables first (never auth).
5
+ *
6
+ * It adapts to whichever modules you scaffolded: each block runs only if
7
+ * its table is present in the schema barrel, so the same script works for
8
+ * any module combination.
9
+ *
10
+ * Run:
11
+ * pnpm db:seed
12
+ */
13
+ import crypto from 'node:crypto';
14
+ import { config } from 'dotenv';
15
+ // Load .env.local first (precedence) then .env, so DATABASE_URL is found
16
+ // when this runs as a standalone tsx script (Next loads .env.local; the
17
+ // raw tsx runtime does not).
18
+ config({ path: '.env.local' });
19
+ config({ path: '.env' });
20
+
21
+ import { faker } from '@faker-js/faker';
22
+ import { db } from '../src/db/client';
23
+ import * as schemaNs from '../src/db/schema';
24
+
25
+ // The schema barrel only re-exports tables for installed modules, so its
26
+ // shape varies per scaffold. Access via a permissive map + runtime guards.
27
+ const s = schemaNs as Record<string, any>;
28
+
29
+ faker.seed(42);
30
+
31
+ const TAGS = ['vip', 'recurring', 'commercial', 'residential', 'lead'] as const;
32
+ const SERVICE_TYPES = ['Service Call', 'Install', 'Repair', 'Maintenance', 'Tune-up', 'Emergency'];
33
+ const LEAD_SOURCES = ['Google', 'Referral', 'Yelp', 'Repeat customer', 'Trade show', 'Facebook'];
34
+ const JOB_STATUSES = ['lead', 'estimate', 'scheduled', 'dispatched', 'in_progress', 'completed', 'invoiced', 'paid'] as const;
35
+ const PRIORITIES = ['low', 'normal', 'high', 'emergency'] as const;
36
+ const WINDOWS = ['8-10am', '10am-12pm', '12-2pm', '2-4pm', '4-6pm'];
37
+
38
+ function token() {
39
+ return crypto.randomBytes(16).toString('hex');
40
+ }
41
+ function randomAddress() {
42
+ return {
43
+ line1: faker.location.streetAddress(),
44
+ city: faker.location.city(),
45
+ state: faker.location.state({ abbreviated: true }),
46
+ postalCode: faker.location.zipCode('#####'),
47
+ };
48
+ }
49
+ function pick<T>(arr: T[]): T {
50
+ return faker.helpers.arrayElement(arr);
51
+ }
52
+
53
+ async function safeDelete(table: unknown, label: string) {
54
+ if (!table) return;
55
+ try {
56
+ await db.delete(table as never);
57
+ } catch (err) {
58
+ console.warn(`[seed] could not clear ${label}:`, (err as Error).message);
59
+ }
60
+ }
61
+
62
+ async function main() {
63
+ console.log('[seed] Clearing existing demo data (business tables only)...');
64
+ // Delete children before parents to satisfy FK constraints. Auth tables
65
+ // (user/session/account/verification) are intentionally never touched.
66
+ await safeDelete(s.invoicePayments, 'invoice_payments');
67
+ await safeDelete(s.invoices, 'invoices');
68
+ await safeDelete(s.estimates, 'estimates');
69
+ await safeDelete(s.jobChecklists, 'job_checklists');
70
+ await safeDelete(s.jobAttachments, 'job_attachments');
71
+ await safeDelete(s.smsMessages, 'sms_messages');
72
+ await safeDelete(s.servicePlanSubscriptions, 'service_plan_subscriptions');
73
+ await safeDelete(s.servicePlans, 'service_plans');
74
+ await safeDelete(s.jobs, 'jobs');
75
+ await safeDelete(s.priceBookItems, 'price_book_items');
76
+ await safeDelete(s.priceBookCategories, 'price_book_categories');
77
+ await safeDelete(s.customers, 'customers');
78
+
79
+ if (!s.customers) {
80
+ console.log('[seed] No customers table — nothing to seed.');
81
+ return;
82
+ }
83
+
84
+ // ---- Customers ----
85
+ console.log('[seed] Inserting 20 customers...');
86
+ const customerRows = await db
87
+ .insert(s.customers)
88
+ .values(
89
+ Array.from({ length: 20 }, () => {
90
+ const isCommercial = faker.datatype.boolean({ probability: 0.35 });
91
+ const billing = randomAddress();
92
+ return {
93
+ name: isCommercial ? faker.company.name() : faker.person.fullName(),
94
+ phones: [faker.phone.number({ style: 'national' })],
95
+ emails: [faker.internet.email().toLowerCase()],
96
+ billingAddress: billing,
97
+ serviceAddresses: faker.datatype.boolean({ probability: 0.4 }) ? [randomAddress()] : [billing],
98
+ tags: faker.helpers.arrayElements(TAGS, { min: 1, max: 2 }),
99
+ leadSource: pick(LEAD_SOURCES),
100
+ lifetimeValue: faker.number.int({ min: 0, max: 1_500_000 }),
101
+ balanceDue: faker.datatype.boolean({ probability: 0.25 }) ? faker.number.int({ min: 5_000, max: 200_000 }) : 0,
102
+ doNotContact: false,
103
+ notes: faker.datatype.boolean({ probability: 0.3 }) ? faker.lorem.sentence() : null,
104
+ publicToken: token(),
105
+ };
106
+ }),
107
+ )
108
+ .returning();
109
+
110
+ // ---- Jobs ----
111
+ let jobRows: any[] = [];
112
+ if (s.jobs) {
113
+ console.log('[seed] Inserting 30 jobs...');
114
+ jobRows = await db
115
+ .insert(s.jobs)
116
+ .values(
117
+ Array.from({ length: 30 }, () => {
118
+ const customer = pick(customerRows);
119
+ const status = pick([...JOB_STATUSES]);
120
+ const isScheduled = ['scheduled', 'dispatched', 'in_progress'].includes(status);
121
+ const lineItemCount = ['lead', 'estimate'].includes(status) ? 0 : faker.number.int({ min: 1, max: 3 });
122
+ const lineItems = Array.from({ length: lineItemCount }, () => ({
123
+ description: faker.commerce.productName(),
124
+ qty: faker.number.int({ min: 1, max: 4 }),
125
+ unitPrice: faker.number.int({ min: 5_000, max: 50_000 }),
126
+ }));
127
+ const total = lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
128
+ return {
129
+ customerId: customer.id,
130
+ serviceType: pick(SERVICE_TYPES),
131
+ status,
132
+ priority: pick([...PRIORITIES]),
133
+ scheduledAt: isScheduled ? faker.date.soon({ days: 14 }) : null,
134
+ arrivalWindow: isScheduled ? pick(WINDOWS) : null,
135
+ assigneeIds: [],
136
+ lineItems,
137
+ total,
138
+ notes: faker.datatype.boolean({ probability: 0.2 }) ? faker.lorem.sentence() : null,
139
+ };
140
+ }),
141
+ )
142
+ .returning();
143
+ }
144
+
145
+ // ---- Price Book ----
146
+ if (s.priceBookCategories && s.priceBookItems) {
147
+ console.log('[seed] Inserting price book...');
148
+ const cats = await db
149
+ .insert(s.priceBookCategories)
150
+ .values([
151
+ { name: 'Service & Maintenance', sortOrder: 0 },
152
+ { name: 'Installation', sortOrder: 1 },
153
+ { name: 'Repair', sortOrder: 2 },
154
+ { name: 'Parts & Materials', sortOrder: 3 },
155
+ ])
156
+ .returning();
157
+ const items = Array.from({ length: 16 }, () => {
158
+ const cat = pick(cats);
159
+ const unitCost = faker.number.int({ min: 1_000, max: 120_000 });
160
+ const unitPrice = Math.round(unitCost * faker.number.float({ min: 1.4, max: 2.6 }));
161
+ return {
162
+ categoryId: cat.id,
163
+ kind: pick(['service', 'material', 'labor']),
164
+ name: faker.commerce.productName(),
165
+ description: faker.datatype.boolean() ? faker.commerce.productDescription() : null,
166
+ sku: faker.datatype.boolean({ probability: 0.5 }) ? faker.string.alphanumeric(8).toUpperCase() : null,
167
+ defaultQty: 1,
168
+ unitCost,
169
+ unitPrice,
170
+ taxable: faker.datatype.boolean({ probability: 0.8 }),
171
+ durationMinutes: faker.datatype.boolean() ? faker.helpers.arrayElement([30, 45, 60, 90, 120]) : null,
172
+ };
173
+ });
174
+ await db.insert(s.priceBookItems).values(items);
175
+ }
176
+
177
+ // ---- Estimates ----
178
+ let estimateRows: any[] = [];
179
+ if (s.estimates) {
180
+ console.log('[seed] Inserting estimates...');
181
+ estimateRows = await db
182
+ .insert(s.estimates)
183
+ .values(
184
+ Array.from({ length: 10 }, () => {
185
+ const customer = pick(customerRows);
186
+ const status = pick(['draft', 'sent', 'viewed', 'approved', 'declined']);
187
+ const lineItems = Array.from({ length: faker.number.int({ min: 1, max: 4 }) }, () => {
188
+ const unitCost = faker.number.int({ min: 2_000, max: 80_000 });
189
+ return {
190
+ description: faker.commerce.productName(),
191
+ qty: faker.number.int({ min: 1, max: 3 }),
192
+ unitPrice: Math.round(unitCost * 1.8),
193
+ unitCost,
194
+ taxable: true,
195
+ };
196
+ });
197
+ const subtotal = lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
198
+ const totalCost = lineItems.reduce((acc, li) => acc + li.qty * (li.unitCost ?? 0), 0);
199
+ const sentAt = status === 'draft' ? null : faker.date.recent({ days: 30 });
200
+ return {
201
+ customerId: customer.id,
202
+ status,
203
+ lineItems,
204
+ subtotal,
205
+ total: subtotal,
206
+ totalCost,
207
+ notes: faker.datatype.boolean({ probability: 0.4 }) ? faker.lorem.sentence() : null,
208
+ validUntil: faker.date.soon({ days: 30 }),
209
+ sentAt,
210
+ approvedAt: status === 'approved' ? faker.date.recent({ days: 10 }) : null,
211
+ };
212
+ }),
213
+ )
214
+ .returning();
215
+ }
216
+
217
+ // ---- Invoices + payments ----
218
+ if (s.invoices) {
219
+ console.log('[seed] Inserting invoices + payments...');
220
+ let counter = 1;
221
+ const invoiceRows = await db
222
+ .insert(s.invoices)
223
+ .values(
224
+ Array.from({ length: 8 }, () => {
225
+ const customer = pick(customerRows);
226
+ const status = pick(['sent', 'partial', 'paid', 'overdue']);
227
+ const lineItems = Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => ({
228
+ description: faker.commerce.productName(),
229
+ qty: faker.number.int({ min: 1, max: 3 }),
230
+ unitPrice: faker.number.int({ min: 8_000, max: 90_000 }),
231
+ taxable: true,
232
+ }));
233
+ const total = lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
234
+ const amountPaid = status === 'paid' ? total : status === 'partial' ? Math.round(total / 2) : 0;
235
+ const year = new Date().getUTCFullYear();
236
+ return {
237
+ invoiceNumber: `INV-${year}-${String(counter++).padStart(4, '0')}`,
238
+ customerId: customer.id,
239
+ status,
240
+ lineItems,
241
+ subtotal: total,
242
+ total,
243
+ amountPaid,
244
+ publicToken: token(),
245
+ dueDate: faker.date.soon({ days: 30 }),
246
+ sentAt: faker.date.recent({ days: 20 }),
247
+ paidAt: status === 'paid' ? faker.date.recent({ days: 5 }) : null,
248
+ notes: null,
249
+ };
250
+ }),
251
+ )
252
+ .returning();
253
+
254
+ if (s.invoicePayments) {
255
+ const payments = invoiceRows
256
+ .filter((inv) => inv.amountPaid > 0)
257
+ .map((inv) => ({
258
+ invoiceId: inv.id,
259
+ amount: inv.amountPaid,
260
+ method: pick(['card', 'ach', 'cash', 'check']),
261
+ note: 'Seed payment',
262
+ }));
263
+ if (payments.length > 0) await db.insert(s.invoicePayments).values(payments);
264
+ }
265
+ }
266
+
267
+ // ---- Service plans + subscriptions ----
268
+ if (s.servicePlans) {
269
+ console.log('[seed] Inserting service plans...');
270
+ const plans = await db
271
+ .insert(s.servicePlans)
272
+ .values([
273
+ { name: 'Silver', description: 'Annual tune-up + 10% off repairs', price: 1499, billingInterval: 'monthly', visitsPerYear: 1, perks: 'Annual tune-up · 10% off repairs · Priority booking' },
274
+ { name: 'Gold', description: 'Bi-annual visits + 15% off', price: 2499, billingInterval: 'monthly', visitsPerYear: 2, perks: 'Two visits/year · 15% off repairs · Priority booking' },
275
+ { name: 'Platinum', description: 'Quarterly visits + 20% off', price: 3999, billingInterval: 'monthly', visitsPerYear: 4, perks: 'Quarterly visits · 20% off · After-hours included' },
276
+ ])
277
+ .returning();
278
+ if (s.servicePlanSubscriptions) {
279
+ const subs = faker.helpers.arrayElements(customerRows, 6).map((c) => {
280
+ const renews = new Date();
281
+ renews.setFullYear(renews.getFullYear() + 1);
282
+ return { customerId: c.id, planId: pick(plans).id, status: 'active', renewsAt: renews };
283
+ });
284
+ await db.insert(s.servicePlanSubscriptions).values(subs);
285
+ }
286
+ }
287
+
288
+ // ---- Checklist templates ----
289
+ if (s.checklistTemplates) {
290
+ console.log('[seed] Inserting checklist template...');
291
+ await db.insert(s.checklistTemplates).values([
292
+ {
293
+ name: 'Standard service visit',
294
+ description: 'Default pre/post checks for a service call',
295
+ items: [
296
+ { id: token().slice(0, 8), label: 'Confirm scope with customer', kind: 'checkbox', required: true, sortOrder: 0 },
297
+ { id: token().slice(0, 8), label: 'Before photo', kind: 'photo', required: true, sortOrder: 1 },
298
+ { id: token().slice(0, 8), label: 'Work performed', kind: 'text', required: true, sortOrder: 2 },
299
+ { id: token().slice(0, 8), label: 'After photo', kind: 'photo', required: true, sortOrder: 3 },
300
+ { id: token().slice(0, 8), label: 'Customer signature obtained', kind: 'checkbox', required: false, sortOrder: 4 },
301
+ ],
302
+ },
303
+ ]);
304
+ }
305
+
306
+ console.log(`[seed] Done. ${customerRows.length} customers, ${jobRows.length} jobs + related records.`);
307
+ }
308
+
309
+ main()
310
+ .then(() => process.exit(0))
311
+ .catch((err) => {
312
+ console.error('[seed] Failed:', err);
313
+ process.exit(1);
314
+ });
@@ -0,0 +1,114 @@
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 { getServicePlan, getSubscriptionsForPlan } from '@/lib/service-plans/data';
6
+ import {
7
+ BILLING_INTERVAL_LABEL,
8
+ SUBSCRIPTION_STATUS_LABEL,
9
+ SUBSCRIPTION_STATUS_VARIANT,
10
+ } from '@/lib/service-plans/types';
11
+ import { SubscribeCustomerSection } from '@/components/service-plans/subscribe-customer-section';
12
+ import { formatCurrency, formatDate } from '@/lib/utils';
13
+
14
+ interface PageProps { params: Promise<{ id: string }>; }
15
+
16
+ export default async function ServicePlanDetailPage({ params }: PageProps) {
17
+ const { id } = await params;
18
+ const plan = await getServicePlan(id);
19
+ if (!plan) notFound();
20
+ const subs = await getSubscriptionsForPlan(id);
21
+
22
+ return (
23
+ <div className="space-y-6">
24
+ <div>
25
+ <Link href="/service-plans" className="text-muted-foreground text-sm hover:underline">
26
+ ← Service plans
27
+ </Link>
28
+ <div className="mt-1 flex items-center gap-3">
29
+ <h1 className="text-3xl font-bold tracking-tight">{plan.name}</h1>
30
+ <Badge variant="outline">{BILLING_INTERVAL_LABEL[plan.billingInterval]}</Badge>
31
+ </div>
32
+ {plan.description && <p className="text-muted-foreground mt-1">{plan.description}</p>}
33
+ </div>
34
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
35
+ <Card>
36
+ <CardHeader><CardTitle className="text-sm font-medium">Price</CardTitle></CardHeader>
37
+ <CardContent>
38
+ <div className="text-2xl font-bold">{formatCurrency(plan.price)}</div>
39
+ <div className="text-muted-foreground text-xs">
40
+ per {plan.billingInterval === 'monthly' ? 'month' : plan.billingInterval === 'quarterly' ? 'quarter' : 'year'}
41
+ </div>
42
+ </CardContent>
43
+ </Card>
44
+ <Card>
45
+ <CardHeader><CardTitle className="text-sm font-medium">Visits / year</CardTitle></CardHeader>
46
+ <CardContent className="text-2xl font-bold">{plan.visitsPerYear}</CardContent>
47
+ </Card>
48
+ <Card>
49
+ <CardHeader><CardTitle className="text-sm font-medium">Subscribers</CardTitle></CardHeader>
50
+ <CardContent className="text-2xl font-bold">{plan.activeSubscribers}</CardContent>
51
+ </Card>
52
+ <Card>
53
+ <CardHeader><CardTitle className="text-sm font-medium">MRR</CardTitle></CardHeader>
54
+ <CardContent className="text-2xl font-bold text-emerald-600">
55
+ {formatCurrency(plan.monthlyRecurringRevenue)}
56
+ </CardContent>
57
+ </Card>
58
+ </div>
59
+ {plan.perks && (
60
+ <Card>
61
+ <CardHeader><CardTitle>Perks</CardTitle></CardHeader>
62
+ <CardContent className="text-sm">
63
+ {plan.perks.split('·').map((perk, i) => (
64
+ <div key={i} className="flex items-start gap-2 py-1">
65
+ <span className="text-emerald-600">✓</span>
66
+ <span>{perk.trim()}</span>
67
+ </div>
68
+ ))}
69
+ </CardContent>
70
+ </Card>
71
+ )}
72
+ <Card>
73
+ <CardHeader><CardTitle>Subscribers ({subs.length})</CardTitle></CardHeader>
74
+ <CardContent className="p-0">
75
+ {subs.length === 0 ? (
76
+ <p className="text-muted-foreground p-6 text-sm">No active subscribers yet.</p>
77
+ ) : (
78
+ <table className="w-full text-sm">
79
+ <thead className="text-muted-foreground bg-muted/50 text-xs uppercase">
80
+ <tr>
81
+ <th className="px-4 py-3 text-left font-medium">Customer</th>
82
+ <th className="px-4 py-3 text-left font-medium">Started</th>
83
+ <th className="px-4 py-3 text-left font-medium">Renews</th>
84
+ <th className="px-4 py-3 text-left font-medium">Status</th>
85
+ </tr>
86
+ </thead>
87
+ <tbody className="divide-border divide-y">
88
+ {subs.map((s) => (
89
+ <tr key={s.id}>
90
+ <td className="px-4 py-3 font-medium">
91
+ <Link href={`/customers/${s.customerId}`} className="hover:underline">
92
+ {s.customerName}
93
+ </Link>
94
+ </td>
95
+ <td className="text-muted-foreground px-4 py-3">{formatDate(s.startedAt)}</td>
96
+ <td className="text-muted-foreground px-4 py-3">
97
+ {s.renewsAt ? formatDate(s.renewsAt) : '—'}
98
+ </td>
99
+ <td className="px-4 py-3">
100
+ <Badge variant={SUBSCRIPTION_STATUS_VARIANT[s.status]}>
101
+ {SUBSCRIPTION_STATUS_LABEL[s.status]}
102
+ </Badge>
103
+ </td>
104
+ </tr>
105
+ ))}
106
+ </tbody>
107
+ </table>
108
+ )}
109
+ </CardContent>
110
+ </Card>
111
+ <SubscribeCustomerSection planId={plan.id} />
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,18 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2
+
3
+ /**
4
+ * Default stub. Drizzle's installer overwrites this with a real form.
5
+ */
6
+ export default function NewServicePlanPage() {
7
+ return (
8
+ <div className="mx-auto max-w-2xl space-y-6">
9
+ <h1 className="text-3xl font-bold tracking-tight">New service plan</h1>
10
+ <Card>
11
+ <CardHeader><CardTitle>Form goes here</CardTitle></CardHeader>
12
+ <CardContent className="text-muted-foreground text-sm">
13
+ Wire this to your stack to enable.
14
+ </CardContent>
15
+ </Card>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,92 @@
1
+ import Link from 'next/link';
2
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { getServicePlans } from '@/lib/service-plans/data';
6
+ import { BILLING_INTERVAL_LABEL } from '@/lib/service-plans/types';
7
+ import { formatCurrency } from '@/lib/utils';
8
+
9
+ export default async function ServicePlansPage() {
10
+ const plans = await getServicePlans();
11
+ const totalMrr = plans.reduce((acc, p) => acc + p.monthlyRecurringRevenue, 0);
12
+ const totalSubs = plans.reduce((acc, p) => acc + p.activeSubscribers, 0);
13
+
14
+ return (
15
+ <div className="space-y-6">
16
+ <div className="flex items-center justify-between">
17
+ <div>
18
+ <h1 className="text-3xl font-bold tracking-tight">Service plans</h1>
19
+ <p className="text-muted-foreground mt-1 text-sm">
20
+ Recurring revenue products — memberships, maintenance agreements, etc.
21
+ </p>
22
+ </div>
23
+ <Button asChild>
24
+ <Link href="/service-plans/new">New plan</Link>
25
+ </Button>
26
+ </div>
27
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
28
+ <Card>
29
+ <CardHeader><CardTitle className="text-base">Total MRR</CardTitle></CardHeader>
30
+ <CardContent>
31
+ <div className="text-3xl font-bold text-emerald-600">{formatCurrency(totalMrr)}</div>
32
+ <p className="text-muted-foreground mt-1 text-xs">Across all active subscribers</p>
33
+ </CardContent>
34
+ </Card>
35
+ <Card>
36
+ <CardHeader><CardTitle className="text-base">Active subscribers</CardTitle></CardHeader>
37
+ <CardContent>
38
+ <div className="text-3xl font-bold">{totalSubs}</div>
39
+ <p className="text-muted-foreground mt-1 text-xs">Across {plans.length} plan{plans.length === 1 ? '' : 's'}</p>
40
+ </CardContent>
41
+ </Card>
42
+ <Card>
43
+ <CardHeader><CardTitle className="text-base">Annualized</CardTitle></CardHeader>
44
+ <CardContent>
45
+ <div className="text-3xl font-bold">{formatCurrency(totalMrr * 12)}</div>
46
+ <p className="text-muted-foreground mt-1 text-xs">ARR if no churn</p>
47
+ </CardContent>
48
+ </Card>
49
+ </div>
50
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
51
+ {plans.map((p) => (
52
+ <Card key={p.id} className={p.name === 'Gold' ? 'border-brand border-2' : ''}>
53
+ <CardHeader>
54
+ <div className="flex items-center justify-between">
55
+ <CardTitle>{p.name}</CardTitle>
56
+ <Badge variant="outline">{BILLING_INTERVAL_LABEL[p.billingInterval]}</Badge>
57
+ </div>
58
+ <div className="mt-1">
59
+ <span className="text-3xl font-bold">{formatCurrency(p.price)}</span>
60
+ <span className="text-muted-foreground text-sm">
61
+ /{p.billingInterval === 'monthly' ? 'mo' : p.billingInterval === 'quarterly' ? 'qtr' : 'yr'}
62
+ </span>
63
+ </div>
64
+ </CardHeader>
65
+ <CardContent className="space-y-3 text-sm">
66
+ {p.description && <p className="text-muted-foreground">{p.description}</p>}
67
+ <div className="grid grid-cols-2 gap-2 text-xs">
68
+ <div>
69
+ <div className="text-muted-foreground">Visits/yr</div>
70
+ <div className="text-base font-bold">{p.visitsPerYear}</div>
71
+ </div>
72
+ <div>
73
+ <div className="text-muted-foreground">Subscribers</div>
74
+ <div className="text-base font-bold">{p.activeSubscribers}</div>
75
+ </div>
76
+ </div>
77
+ <div className="border-t pt-3">
78
+ <div className="text-muted-foreground text-xs uppercase">MRR</div>
79
+ <div className="text-lg font-bold text-emerald-600">
80
+ {formatCurrency(p.monthlyRecurringRevenue)}
81
+ </div>
82
+ </div>
83
+ <Button asChild variant="outline" size="sm" className="w-full">
84
+ <Link href={`/service-plans/${p.id}`}>View subscribers</Link>
85
+ </Button>
86
+ </CardContent>
87
+ </Card>
88
+ ))}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,17 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2
+
3
+ /**
4
+ * Default stub. Drizzle's installer overwrites this with a Stripe-Checkout-
5
+ * backed working version.
6
+ */
7
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
8
+ export function SubscribeCustomerSection({ planId }: { planId: string }) {
9
+ return (
10
+ <Card>
11
+ <CardHeader><CardTitle>Subscribe a customer</CardTitle></CardHeader>
12
+ <CardContent className="text-muted-foreground text-sm">
13
+ Stripe-backed subscriptions ship on the Drizzle stack. Configure STRIPE_SECRET_KEY in <code>.env.local</code> to enable.
14
+ </CardContent>
15
+ </Card>
16
+ );
17
+ }
@@ -0,0 +1,14 @@
1
+ import { sampleServicePlans, sampleSubscriptions } from './sample-data';
2
+ import type { ServicePlan, ServicePlanSubscription } from './types';
3
+
4
+ export async function getServicePlans(): Promise<ServicePlan[]> {
5
+ return sampleServicePlans;
6
+ }
7
+
8
+ export async function getServicePlan(id: string): Promise<ServicePlan | null> {
9
+ return sampleServicePlans.find((p) => p.id === id) ?? null;
10
+ }
11
+
12
+ export async function getSubscriptionsForPlan(planId: string): Promise<ServicePlanSubscription[]> {
13
+ return sampleSubscriptions.filter((s) => s.planId === planId);
14
+ }
@@ -0,0 +1,83 @@
1
+ import type { ServicePlan, ServicePlanSubscription } from './types';
2
+
3
+ function arr(price: number, interval: 'monthly' | 'quarterly' | 'annual'): number {
4
+ if (interval === 'monthly') return price * 12;
5
+ if (interval === 'quarterly') return price * 4;
6
+ return price;
7
+ }
8
+
9
+ function mrr(price: number, interval: 'monthly' | 'quarterly' | 'annual', subs: number): number {
10
+ return Math.round((arr(price, interval) / 12) * subs);
11
+ }
12
+
13
+ export const sampleServicePlans: ServicePlan[] = [
14
+ {
15
+ id: 'plan_silver',
16
+ name: 'Silver',
17
+ description: 'Annual tune-up + 10% off any repairs',
18
+ price: 1499,
19
+ billingInterval: 'monthly',
20
+ visitsPerYear: 1,
21
+ perks: 'Annual tune-up · 10% off repairs · Priority booking',
22
+ arrPerSubscriber: arr(1499, 'monthly'),
23
+ activeSubscribers: 12,
24
+ monthlyRecurringRevenue: mrr(1499, 'monthly', 12),
25
+ },
26
+ {
27
+ id: 'plan_gold',
28
+ name: 'Gold',
29
+ description: 'Bi-annual visits, 15% off, priority booking',
30
+ price: 2499,
31
+ billingInterval: 'monthly',
32
+ visitsPerYear: 2,
33
+ perks: 'Two visits/year · 15% off repairs · Priority booking · Waived diagnostic fee',
34
+ arrPerSubscriber: arr(2499, 'monthly'),
35
+ activeSubscribers: 7,
36
+ monthlyRecurringRevenue: mrr(2499, 'monthly', 7),
37
+ },
38
+ {
39
+ id: 'plan_platinum',
40
+ name: 'Platinum',
41
+ description: 'Quarterly visits, 20% off, after-hours coverage',
42
+ price: 3999,
43
+ billingInterval: 'monthly',
44
+ visitsPerYear: 4,
45
+ perks: 'Quarterly visits · 20% off repairs · After-hours included · Equipment replacement priority',
46
+ arrPerSubscriber: arr(3999, 'monthly'),
47
+ activeSubscribers: 3,
48
+ monthlyRecurringRevenue: mrr(3999, 'monthly', 3),
49
+ },
50
+ ];
51
+
52
+ export const sampleSubscriptions: ServicePlanSubscription[] = [
53
+ {
54
+ id: 'sub_001',
55
+ customerId: 'cus_001',
56
+ customerName: 'Acme Property Management',
57
+ planId: 'plan_platinum',
58
+ planName: 'Platinum',
59
+ status: 'active',
60
+ startedAt: '2025-09-15T00:00:00Z',
61
+ renewsAt: '2026-09-15T00:00:00Z',
62
+ },
63
+ {
64
+ id: 'sub_002',
65
+ customerId: 'cus_002',
66
+ customerName: 'Jamie Rodriguez',
67
+ planId: 'plan_gold',
68
+ planName: 'Gold',
69
+ status: 'active',
70
+ startedAt: '2026-01-08T00:00:00Z',
71
+ renewsAt: '2027-01-08T00:00:00Z',
72
+ },
73
+ {
74
+ id: 'sub_003',
75
+ customerId: 'cus_003',
76
+ customerName: 'Mason Hardware Co.',
77
+ planId: 'plan_silver',
78
+ planName: 'Silver',
79
+ status: 'active',
80
+ startedAt: '2025-11-22T00:00:00Z',
81
+ renewsAt: '2026-11-22T00:00:00Z',
82
+ },
83
+ ];