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.
- package/README.md +53 -0
- package/dist/index.js +2341 -0
- package/package.json +51 -0
- package/template/base/_dot_gitignore +11 -0
- package/template/base/eslint.config.mjs +7 -0
- package/template/base/next.config.ts +17 -0
- package/template/base/package.json +35 -0
- package/template/base/postcss.config.mjs +7 -0
- package/template/base/scripts/setup.mjs +146 -0
- package/template/base/src/app/globals.css +68 -0
- package/template/base/src/app/layout.tsx +19 -0
- package/template/base/src/app/page.tsx +37 -0
- package/template/base/src/components/ui/badge.tsx +29 -0
- package/template/base/src/components/ui/button.tsx +49 -0
- package/template/base/src/components/ui/card.tsx +54 -0
- package/template/base/src/components/ui/input.tsx +19 -0
- package/template/base/src/components/ui/label.tsx +19 -0
- package/template/base/src/components/ui/separator.tsx +25 -0
- package/template/base/src/components/ui/skeleton.tsx +7 -0
- package/template/base/src/lib/utils.ts +17 -0
- package/template/base/tsconfig.json +21 -0
- package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
- package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
- package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
- package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
- package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
- package/template/extras/auth-better-auth/src/middleware.ts +25 -0
- package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
- package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
- package/template/extras/auth-clerk/src/middleware.ts +17 -0
- package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
- package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
- package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
- package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
- package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
- package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
- package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
- package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
- package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
- package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
- package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
- package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
- package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
- package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
- package/template/extras/customers/_fragments/convex.txt +28 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
- package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
- package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
- package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
- package/template/extras/customers/convex/convex/customers.ts +52 -0
- package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
- package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
- package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
- package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
- package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
- package/template/extras/db-convex/convex/_generated/README.md +13 -0
- package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
- package/template/extras/db-convex/convex/_generated/api.js +12 -0
- package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
- package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
- package/template/extras/db-convex/convex/_generated/server.js +12 -0
- package/template/extras/db-convex/convex/auth.config.ts +17 -0
- package/template/extras/db-convex/convex/schema.ts +28 -0
- package/template/extras/db-convex/src/app/layout.tsx +20 -0
- package/template/extras/db-convex/src/components/providers.tsx +28 -0
- package/template/extras/db-convex/src/lib/convex.ts +6 -0
- package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
- package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
- package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
- package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
- package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
- package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
- package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
- package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
- package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
- package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
- package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
- package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
- package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
- package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
- package/template/extras/jobs/_fragments/convex.txt +21 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
- package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
- package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
- package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
- package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
- package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
- package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
- package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
- package/template/extras/jobs/convex/convex/jobs.ts +46 -0
- package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
- package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
- package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
- package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
- package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
- package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
- package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
- package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
- package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
- package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
- package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
- package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
- package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
- package/template/extras/mobile/mobile/README.md +67 -0
- package/template/extras/mobile/mobile/_dot_env.example +5 -0
- package/template/extras/mobile/mobile/_dot_gitignore +26 -0
- package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
- package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
- package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
- package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
- package/template/extras/mobile/mobile/app/index.tsx +23 -0
- package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
- package/template/extras/mobile/mobile/app.brand.ts +14 -0
- package/template/extras/mobile/mobile/app.config.ts +40 -0
- package/template/extras/mobile/mobile/app.features.ts +11 -0
- package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
- package/template/extras/mobile/mobile/eas.json +22 -0
- package/template/extras/mobile/mobile/lib/api.ts +253 -0
- package/template/extras/mobile/mobile/lib/auth.ts +51 -0
- package/template/extras/mobile/mobile/lib/format.ts +23 -0
- package/template/extras/mobile/mobile/lib/push.ts +24 -0
- package/template/extras/mobile/mobile/lib/theme.ts +16 -0
- package/template/extras/mobile/mobile/package.json +34 -0
- package/template/extras/mobile/mobile/tsconfig.json +11 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
- package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
- package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
- package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
- package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
- package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
- package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
- package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
- package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
- package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
- package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
- package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
- package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
- package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
- package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
- package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
- package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
- package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
- package/template/extras/seed/_shared/scripts/seed.ts +35 -0
- package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
- package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
- package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
- package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
- package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { asc, desc, eq, isNull, sql } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import { checklistTemplates, jobChecklists } from '@/db/schema';
|
|
4
|
+
import type {
|
|
5
|
+
ChecklistTemplate,
|
|
6
|
+
ChecklistTemplateItem,
|
|
7
|
+
JobChecklist,
|
|
8
|
+
JobChecklistItem,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
function toTemplate(
|
|
12
|
+
row: typeof checklistTemplates.$inferSelect,
|
|
13
|
+
attachedJobCount: number,
|
|
14
|
+
): ChecklistTemplate {
|
|
15
|
+
const items = (row.items as ChecklistTemplateItem[]) ?? [];
|
|
16
|
+
return {
|
|
17
|
+
id: row.id,
|
|
18
|
+
name: row.name,
|
|
19
|
+
description: row.description ?? undefined,
|
|
20
|
+
items,
|
|
21
|
+
itemCount: items.length,
|
|
22
|
+
requiredCount: items.filter((i) => i.required).length,
|
|
23
|
+
attachedJobCount,
|
|
24
|
+
createdAt: row.createdAt.toISOString(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getChecklistTemplates(): Promise<ChecklistTemplate[]> {
|
|
29
|
+
const rows = await db
|
|
30
|
+
.select({
|
|
31
|
+
template: checklistTemplates,
|
|
32
|
+
attachedJobCount: sql<number>`COALESCE(COUNT(DISTINCT ${jobChecklists.jobId}), 0)::int`,
|
|
33
|
+
})
|
|
34
|
+
.from(checklistTemplates)
|
|
35
|
+
.leftJoin(jobChecklists, eq(checklistTemplates.id, jobChecklists.templateId))
|
|
36
|
+
.where(isNull(checklistTemplates.archivedAt))
|
|
37
|
+
.groupBy(checklistTemplates.id)
|
|
38
|
+
.orderBy(asc(checklistTemplates.name));
|
|
39
|
+
return rows.map((r) => toTemplate(r.template, r.attachedJobCount));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function getChecklistTemplate(id: string): Promise<ChecklistTemplate | null> {
|
|
43
|
+
const [row] = await db
|
|
44
|
+
.select({
|
|
45
|
+
template: checklistTemplates,
|
|
46
|
+
attachedJobCount: sql<number>`COALESCE(COUNT(DISTINCT ${jobChecklists.jobId}), 0)::int`,
|
|
47
|
+
})
|
|
48
|
+
.from(checklistTemplates)
|
|
49
|
+
.leftJoin(jobChecklists, eq(checklistTemplates.id, jobChecklists.templateId))
|
|
50
|
+
.where(eq(checklistTemplates.id, id))
|
|
51
|
+
.groupBy(checklistTemplates.id)
|
|
52
|
+
.limit(1);
|
|
53
|
+
if (!row) return null;
|
|
54
|
+
return toTemplate(row.template, row.attachedJobCount);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toJobChecklist(row: typeof jobChecklists.$inferSelect): JobChecklist {
|
|
58
|
+
const items = (row.items as JobChecklistItem[]) ?? [];
|
|
59
|
+
const completedCount = items.filter((i) => i.completed).length;
|
|
60
|
+
const requiredItems = items.filter((i) => i.required);
|
|
61
|
+
const allRequiredComplete = requiredItems.every((i) => i.completed);
|
|
62
|
+
return {
|
|
63
|
+
id: row.id,
|
|
64
|
+
jobId: row.jobId,
|
|
65
|
+
templateId: row.templateId ?? undefined,
|
|
66
|
+
templateName: row.templateName,
|
|
67
|
+
items,
|
|
68
|
+
allRequiredComplete,
|
|
69
|
+
progressPct: items.length === 0 ? 0 : Math.round((completedCount / items.length) * 100),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getJobChecklists(jobId: string): Promise<JobChecklist[]> {
|
|
74
|
+
const rows = await db
|
|
75
|
+
.select()
|
|
76
|
+
.from(jobChecklists)
|
|
77
|
+
.where(eq(jobChecklists.jobId, jobId))
|
|
78
|
+
.orderBy(desc(jobChecklists.attachedAt));
|
|
79
|
+
return rows.map(toJobChecklist);
|
|
80
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
|
|
3
|
+
const TEMPLATES = [
|
|
4
|
+
{ id: 'appt_confirm', name: 'Appointment confirmation', desc: 'Sent immediately after booking.' },
|
|
5
|
+
{ id: 'reminder_24h', name: '24h reminder', desc: 'Sent 24 hours before scheduled job.' },
|
|
6
|
+
{ id: 'on_the_way', name: 'On the way', desc: 'Sent when tech taps "Start route" in field app.' },
|
|
7
|
+
{ id: 'invoice_sent', name: 'Invoice sent', desc: 'Sent when an invoice is finalized.' },
|
|
8
|
+
{ id: 'review_request', name: 'Review request', desc: 'Sent 24h after job completion.' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export default function EmailPage() {
|
|
12
|
+
return (
|
|
13
|
+
<div className="space-y-6">
|
|
14
|
+
<div>
|
|
15
|
+
<h1 className="text-3xl font-bold tracking-tight">Email templates</h1>
|
|
16
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
17
|
+
Powered by Resend. Templates live in <code>src/emails/</code> (build with react-email).
|
|
18
|
+
</p>
|
|
19
|
+
</div>
|
|
20
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
21
|
+
{TEMPLATES.map((t) => (
|
|
22
|
+
<Card key={t.id}>
|
|
23
|
+
<CardHeader>
|
|
24
|
+
<CardTitle className="text-base">{t.name}</CardTitle>
|
|
25
|
+
</CardHeader>
|
|
26
|
+
<CardContent className="text-muted-foreground text-sm">{t.desc}</CardContent>
|
|
27
|
+
</Card>
|
|
28
|
+
))}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default stub. The Drizzle stack overwrites this with a real Twilio
|
|
7
|
+
* composer (recipient picker + templates + send). On other stacks, SMS
|
|
8
|
+
* sending isn't wired yet — this page explains that instead of 404-ing.
|
|
9
|
+
*/
|
|
10
|
+
export default function NewSmsStubPage() {
|
|
11
|
+
return (
|
|
12
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
13
|
+
<div>
|
|
14
|
+
<Link href="/sms" className="text-muted-foreground text-sm hover:underline">← Inbox</Link>
|
|
15
|
+
<h1 className="mt-1 text-3xl font-bold tracking-tight">New message</h1>
|
|
16
|
+
</div>
|
|
17
|
+
<Card>
|
|
18
|
+
<CardHeader><CardTitle>SMS sending isn't wired on this stack yet</CardTitle></CardHeader>
|
|
19
|
+
<CardContent className="text-muted-foreground space-y-3 text-sm">
|
|
20
|
+
<p>
|
|
21
|
+
Two-way SMS (Twilio send + inbound webhook + threaded inbox) currently
|
|
22
|
+
ships on the <strong>Better-Auth + Postgres + Drizzle</strong> stack.
|
|
23
|
+
</p>
|
|
24
|
+
<p>
|
|
25
|
+
To enable it, scaffold with that stack, or implement the Twilio
|
|
26
|
+
send action for your stack and replace this page.
|
|
27
|
+
</p>
|
|
28
|
+
<Button asChild variant="outline" size="sm">
|
|
29
|
+
<Link href="/sms">Back to inbox</Link>
|
|
30
|
+
</Button>
|
|
31
|
+
</CardContent>
|
|
32
|
+
</Card>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card } from '@/components/ui/card';
|
|
3
|
+
import { Badge } from '@/components/ui/badge';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
|
|
6
|
+
const THREADS = [
|
|
7
|
+
{ id: 't1', customer: 'Jamie Rodriguez', last: 'On my way — see you in 10!', unread: 0, at: '11:42 AM' },
|
|
8
|
+
{ id: 't2', customer: 'Acme Property Management', last: 'Can we reschedule to Thursday?', unread: 2, at: '9:05 AM' },
|
|
9
|
+
{ id: 't3', customer: 'Priya Patel', last: 'Thanks — confirmed.', unread: 0, at: 'Yesterday' },
|
|
10
|
+
{ id: 't4', customer: 'Mason Hardware', last: 'Sending the W-9 now.', unread: 1, at: '2 days ago' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
interface PageProps { searchParams: Promise<{ sent?: string }>; }
|
|
14
|
+
|
|
15
|
+
export default async function SmsPage({ searchParams }: PageProps) {
|
|
16
|
+
const sp = await searchParams;
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
|
|
19
|
+
<div className="flex items-center justify-between">
|
|
20
|
+
<h1 className="text-2xl font-bold tracking-tight">Inbox</h1>
|
|
21
|
+
<Button asChild>
|
|
22
|
+
<Link href="/sms/new">New message</Link>
|
|
23
|
+
</Button>
|
|
24
|
+
</div>
|
|
25
|
+
{sp.sent && (
|
|
26
|
+
<div className="bg-emerald-50 border-emerald-200 text-emerald-900 rounded-md border px-3 py-2 text-sm">
|
|
27
|
+
✓ Message sent.
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
<div className="grid flex-1 grid-cols-[20rem_1fr] gap-4 overflow-hidden">
|
|
31
|
+
<Card className="flex flex-col">
|
|
32
|
+
<div className="border-b p-3 font-semibold">Threads</div>
|
|
33
|
+
<div className="flex-1 overflow-y-auto">
|
|
34
|
+
{THREADS.map((t) => (
|
|
35
|
+
<div key={t.id} className="hover:bg-muted/30 cursor-pointer border-b p-3 text-sm">
|
|
36
|
+
<div className="flex items-center justify-between">
|
|
37
|
+
<div className="font-medium">{t.customer}</div>
|
|
38
|
+
{t.unread > 0 && <Badge>{t.unread}</Badge>}
|
|
39
|
+
</div>
|
|
40
|
+
<div className="text-muted-foreground mt-1 truncate text-xs">{t.last}</div>
|
|
41
|
+
<div className="text-muted-foreground mt-0.5 text-[10px]">{t.at}</div>
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
</Card>
|
|
46
|
+
<Card className="flex items-center justify-center">
|
|
47
|
+
<p className="text-muted-foreground text-sm">
|
|
48
|
+
Select a thread to view the conversation. (Inbound webhook + threaded
|
|
49
|
+
history land in a later module.)
|
|
50
|
+
</p>
|
|
51
|
+
</Card>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { db } from '@/db/client';
|
|
5
|
+
import { customers } from '@/db/schema';
|
|
6
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
7
|
+
import { Button } from '@/components/ui/button';
|
|
8
|
+
import { Input } from '@/components/ui/input';
|
|
9
|
+
import { getMessagesForCustomer } from '@/lib/sms/data';
|
|
10
|
+
import { sendSms } from '@/lib/sms/actions';
|
|
11
|
+
import { formatDate } from '@/lib/utils';
|
|
12
|
+
|
|
13
|
+
interface PageProps {
|
|
14
|
+
params: Promise<{ customerId: string }>;
|
|
15
|
+
searchParams: Promise<{ sent?: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function SmsThreadPage({ params, searchParams }: PageProps) {
|
|
19
|
+
const { customerId } = await params;
|
|
20
|
+
const sp = await searchParams;
|
|
21
|
+
const [customer] = await db
|
|
22
|
+
.select({ id: customers.id, name: customers.name, phones: customers.phones })
|
|
23
|
+
.from(customers)
|
|
24
|
+
.where(eq(customers.id, customerId))
|
|
25
|
+
.limit(1);
|
|
26
|
+
if (!customer) notFound();
|
|
27
|
+
|
|
28
|
+
const phones = (customer.phones as string[]) ?? [];
|
|
29
|
+
const phone = phones[0] ?? '';
|
|
30
|
+
const messages = await getMessagesForCustomer(customerId);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
|
|
34
|
+
<div>
|
|
35
|
+
<Link href="/sms" className="text-muted-foreground text-sm hover:underline">← Inbox</Link>
|
|
36
|
+
<h1 className="mt-1 text-2xl font-bold tracking-tight">{customer.name}</h1>
|
|
37
|
+
{phone && (
|
|
38
|
+
<p className="text-muted-foreground text-sm">
|
|
39
|
+
<a href={`tel:${phone}`} className="hover:underline">{phone}</a>
|
|
40
|
+
{' · '}
|
|
41
|
+
<Link href={`/customers/${customer.id}`} className="hover:underline">Customer profile</Link>
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
{sp.sent && (
|
|
45
|
+
<div className="bg-emerald-50 border-emerald-200 text-emerald-900 mt-2 rounded-md border px-3 py-2 text-sm">
|
|
46
|
+
✓ Sent.
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<Card className="flex flex-1 flex-col overflow-hidden">
|
|
52
|
+
<div className="flex-1 space-y-3 overflow-y-auto p-4">
|
|
53
|
+
{messages.length === 0 ? (
|
|
54
|
+
<p className="text-muted-foreground text-center text-sm">
|
|
55
|
+
No messages yet. Send one below.
|
|
56
|
+
</p>
|
|
57
|
+
) : (
|
|
58
|
+
messages.map((m) => (
|
|
59
|
+
<div
|
|
60
|
+
key={m.id}
|
|
61
|
+
className={`flex ${m.direction === 'outbound' ? 'justify-end' : 'justify-start'}`}
|
|
62
|
+
>
|
|
63
|
+
<div
|
|
64
|
+
className={`max-w-[75%] rounded-2xl px-4 py-2 ${
|
|
65
|
+
m.direction === 'outbound'
|
|
66
|
+
? 'bg-brand text-white'
|
|
67
|
+
: 'bg-muted text-foreground'
|
|
68
|
+
}`}
|
|
69
|
+
>
|
|
70
|
+
<p className="text-sm whitespace-pre-wrap">{m.body}</p>
|
|
71
|
+
<p
|
|
72
|
+
className={`mt-1 text-[10px] ${
|
|
73
|
+
m.direction === 'outbound' ? 'text-white/70' : 'text-muted-foreground'
|
|
74
|
+
}`}
|
|
75
|
+
>
|
|
76
|
+
{formatDate(m.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
|
|
77
|
+
{m.direction === 'outbound' && m.status && ` · ${m.status}`}
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
))
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
<form action={sendSms} className="flex items-center gap-2 border-t p-3">
|
|
85
|
+
<input type="hidden" name="customerId" value={customer.id} />
|
|
86
|
+
<input type="hidden" name="to" value={phone} />
|
|
87
|
+
<Input
|
|
88
|
+
name="body"
|
|
89
|
+
required
|
|
90
|
+
maxLength={1600}
|
|
91
|
+
placeholder={phone ? 'Type a reply…' : 'No phone on file for this customer'}
|
|
92
|
+
disabled={!phone}
|
|
93
|
+
className="flex-1"
|
|
94
|
+
/>
|
|
95
|
+
<Button type="submit" disabled={!phone}>
|
|
96
|
+
Send
|
|
97
|
+
</Button>
|
|
98
|
+
</form>
|
|
99
|
+
</Card>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { asc } from 'drizzle-orm';
|
|
3
|
+
import { db } from '@/db/client';
|
|
4
|
+
import { customers } from '@/db/schema';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import { Input } from '@/components/ui/input';
|
|
8
|
+
import { Label } from '@/components/ui/label';
|
|
9
|
+
import { sendSms } from '@/lib/sms/actions';
|
|
10
|
+
|
|
11
|
+
const TEMPLATES = [
|
|
12
|
+
{ label: 'On My Way', body: 'On my way! ETA ~15-30 minutes. Reply if you need to reschedule.' },
|
|
13
|
+
{ label: '24h reminder', body: 'Reminder: your appointment is tomorrow. Reply C to confirm, R to reschedule.' },
|
|
14
|
+
{ label: 'Job finished', body: 'All wrapped up — thanks for choosing us! We\'ll send your invoice shortly.' },
|
|
15
|
+
{ label: 'Review request', body: 'Hope everything looks great. Could we ask for a quick review? It really helps our small business.' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export default async function NewSmsPage() {
|
|
19
|
+
const rows = await db
|
|
20
|
+
.select({ id: customers.id, name: customers.name, phones: customers.phones })
|
|
21
|
+
.from(customers)
|
|
22
|
+
.orderBy(asc(customers.name));
|
|
23
|
+
// Flatten to one row per phone for the dropdown.
|
|
24
|
+
const recipients = rows
|
|
25
|
+
.flatMap((r) =>
|
|
26
|
+
(r.phones as string[]).map((p) => ({
|
|
27
|
+
id: r.id,
|
|
28
|
+
label: `${r.name} — ${p}`,
|
|
29
|
+
phone: p,
|
|
30
|
+
})),
|
|
31
|
+
)
|
|
32
|
+
.slice(0, 200);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
36
|
+
<div>
|
|
37
|
+
<Link href="/sms" className="text-muted-foreground text-sm hover:underline">← Inbox</Link>
|
|
38
|
+
<h1 className="mt-1 text-3xl font-bold tracking-tight">New message</h1>
|
|
39
|
+
</div>
|
|
40
|
+
<form action={sendSms}>
|
|
41
|
+
<Card>
|
|
42
|
+
<CardHeader>
|
|
43
|
+
<CardTitle>Send SMS</CardTitle>
|
|
44
|
+
<CardDescription>
|
|
45
|
+
Powered by Twilio. Make sure <code className="bg-muted rounded px-1.5 py-0.5">TWILIO_*</code>{' '}
|
|
46
|
+
env vars are set in <code className="bg-muted rounded px-1.5 py-0.5">.env.local</code>.
|
|
47
|
+
</CardDescription>
|
|
48
|
+
</CardHeader>
|
|
49
|
+
<CardContent className="space-y-4">
|
|
50
|
+
<div className="space-y-2">
|
|
51
|
+
<Label htmlFor="to">Recipient phone *</Label>
|
|
52
|
+
{recipients.length > 0 && (
|
|
53
|
+
<select
|
|
54
|
+
onChange={(e) => {
|
|
55
|
+
const input = document.getElementById('to') as HTMLInputElement | null;
|
|
56
|
+
if (input && e.target.value) input.value = e.target.value;
|
|
57
|
+
}}
|
|
58
|
+
className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
|
|
59
|
+
defaultValue=""
|
|
60
|
+
>
|
|
61
|
+
<option value="">— Pick a customer to autofill —</option>
|
|
62
|
+
{recipients.map((r, i) => (
|
|
63
|
+
<option key={i} value={r.phone}>{r.label}</option>
|
|
64
|
+
))}
|
|
65
|
+
</select>
|
|
66
|
+
)}
|
|
67
|
+
<Input
|
|
68
|
+
id="to"
|
|
69
|
+
name="to"
|
|
70
|
+
type="tel"
|
|
71
|
+
required
|
|
72
|
+
placeholder="+15125550100 or (512) 555-0100"
|
|
73
|
+
/>
|
|
74
|
+
<p className="text-muted-foreground text-xs">
|
|
75
|
+
Format any way — we normalize to E.164 server-side. US-only without an explicit country code.
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="space-y-2">
|
|
79
|
+
<Label htmlFor="body">Message *</Label>
|
|
80
|
+
<div className="flex flex-wrap gap-1">
|
|
81
|
+
{TEMPLATES.map((t) => (
|
|
82
|
+
<button
|
|
83
|
+
key={t.label}
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={(e) => {
|
|
86
|
+
const textarea = (e.currentTarget.closest('form') as HTMLFormElement)
|
|
87
|
+
?.querySelector('#body') as HTMLTextAreaElement | null;
|
|
88
|
+
if (textarea) textarea.value = t.body;
|
|
89
|
+
}}
|
|
90
|
+
className="bg-muted hover:bg-accent rounded-md px-2 py-1 text-xs"
|
|
91
|
+
>
|
|
92
|
+
{t.label}
|
|
93
|
+
</button>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
<textarea
|
|
97
|
+
id="body"
|
|
98
|
+
name="body"
|
|
99
|
+
rows={5}
|
|
100
|
+
required
|
|
101
|
+
maxLength={1600}
|
|
102
|
+
placeholder="Your message…"
|
|
103
|
+
className="border-input bg-background focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2"
|
|
104
|
+
/>
|
|
105
|
+
<p className="text-muted-foreground text-xs">
|
|
106
|
+
160 chars per segment. Twilio charges per segment.
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
</CardContent>
|
|
110
|
+
</Card>
|
|
111
|
+
<div className="mt-4 flex justify-end gap-3">
|
|
112
|
+
<Button type="button" variant="outline" asChild>
|
|
113
|
+
<Link href="/sms">Cancel</Link>
|
|
114
|
+
</Button>
|
|
115
|
+
<Button type="submit">Send message</Button>
|
|
116
|
+
</div>
|
|
117
|
+
</form>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card } from '@/components/ui/card';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { getSmsThreads } from '@/lib/sms/data';
|
|
5
|
+
import { formatDate } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
interface PageProps { searchParams: Promise<{ sent?: string }>; }
|
|
8
|
+
|
|
9
|
+
export default async function SmsPage({ searchParams }: PageProps) {
|
|
10
|
+
const sp = await searchParams;
|
|
11
|
+
const threads = await getSmsThreads();
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
|
|
14
|
+
<div className="flex items-center justify-between">
|
|
15
|
+
<div>
|
|
16
|
+
<h1 className="text-2xl font-bold tracking-tight">Inbox</h1>
|
|
17
|
+
<p className="text-muted-foreground text-sm">
|
|
18
|
+
{threads.length} thread{threads.length === 1 ? '' : 's'}
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
21
|
+
<Button asChild>
|
|
22
|
+
<Link href="/sms/new">New message</Link>
|
|
23
|
+
</Button>
|
|
24
|
+
</div>
|
|
25
|
+
{sp.sent && (
|
|
26
|
+
<div className="bg-emerald-50 border-emerald-200 text-emerald-900 rounded-md border px-3 py-2 text-sm">
|
|
27
|
+
✓ Message sent.
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
<Card className="flex flex-1 flex-col overflow-hidden">
|
|
31
|
+
{threads.length === 0 ? (
|
|
32
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
|
33
|
+
<p className="text-muted-foreground text-sm">No conversations yet.</p>
|
|
34
|
+
<p className="text-muted-foreground text-xs max-w-md">
|
|
35
|
+
Outbound messages logged here automatically. Inbound replies arrive
|
|
36
|
+
via the Twilio webhook at <code className="bg-muted rounded px-1 py-0.5">/api/twilio/sms</code>{' '}
|
|
37
|
+
— set that URL in your Twilio phone number console.
|
|
38
|
+
</p>
|
|
39
|
+
<Button asChild size="sm" className="mt-2">
|
|
40
|
+
<Link href="/sms/new">Send first message</Link>
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
) : (
|
|
44
|
+
<div className="divide-border divide-y overflow-y-auto">
|
|
45
|
+
{threads.map((t) => (
|
|
46
|
+
<Link
|
|
47
|
+
key={t.customerId}
|
|
48
|
+
href={`/sms/${t.customerId}`}
|
|
49
|
+
className="hover:bg-muted/30 block p-4 transition"
|
|
50
|
+
>
|
|
51
|
+
<div className="flex items-center justify-between gap-3">
|
|
52
|
+
<div className="min-w-0">
|
|
53
|
+
<div className="font-medium">{t.customerName}</div>
|
|
54
|
+
{t.customerPhone && (
|
|
55
|
+
<div className="text-muted-foreground text-xs">{t.customerPhone}</div>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
<div className="text-muted-foreground text-xs whitespace-nowrap">
|
|
59
|
+
{formatDate(t.lastAt, { dateStyle: 'short', timeStyle: 'short' })}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<p className="text-muted-foreground mt-1 truncate text-sm">{t.lastBody}</p>
|
|
63
|
+
</Link>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</Card>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import twilio from 'twilio';
|
|
3
|
+
import { db } from '@/db/client';
|
|
4
|
+
import { smsMessages } from '@/db/schema';
|
|
5
|
+
import { findCustomerByPhone } from '@/lib/sms/data';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Twilio inbound SMS webhook. Configure your phone number's "A message
|
|
9
|
+
* comes in" webhook URL to point at:
|
|
10
|
+
*
|
|
11
|
+
* https://<your-domain>/api/twilio/sms
|
|
12
|
+
*
|
|
13
|
+
* with HTTP POST. We verify the signature using TWILIO_AUTH_TOKEN, look
|
|
14
|
+
* up the sending number against the customers table, and store the
|
|
15
|
+
* message. Response is TwiML; an empty <Response/> means "received, no
|
|
16
|
+
* auto-reply".
|
|
17
|
+
*
|
|
18
|
+
* If you want a touch-tone-style auto-reply (e.g. "Reply STOP to opt out"),
|
|
19
|
+
* add <Message>...</Message> elements to the TwiML below.
|
|
20
|
+
*/
|
|
21
|
+
export async function POST(req: NextRequest) {
|
|
22
|
+
const authToken = process.env.TWILIO_AUTH_TOKEN;
|
|
23
|
+
if (!authToken) {
|
|
24
|
+
return new Response('TWILIO_AUTH_TOKEN not set', { status: 500 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Twilio POSTs application/x-www-form-urlencoded.
|
|
28
|
+
const rawBody = await req.text();
|
|
29
|
+
const params: Record<string, string> = {};
|
|
30
|
+
for (const [k, v] of new URLSearchParams(rawBody).entries()) {
|
|
31
|
+
params[k] = v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Signature verification: Twilio sends X-Twilio-Signature based on full
|
|
35
|
+
// URL + sorted-form-params + HMAC-SHA1(authToken).
|
|
36
|
+
const signature = req.headers.get('x-twilio-signature') ?? '';
|
|
37
|
+
const proto = req.headers.get('x-forwarded-proto') ?? 'https';
|
|
38
|
+
const host = req.headers.get('host') ?? '';
|
|
39
|
+
const url = `${proto}://${host}${req.nextUrl.pathname}${req.nextUrl.search}`;
|
|
40
|
+
const valid = twilio.validateRequest(authToken, signature, url, params);
|
|
41
|
+
if (!valid && process.env.NODE_ENV === 'production') {
|
|
42
|
+
return new Response('Invalid signature', { status: 403 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const from = params.From ?? '';
|
|
46
|
+
const to = params.To ?? '';
|
|
47
|
+
const body = params.Body ?? '';
|
|
48
|
+
const messageSid = params.MessageSid ?? '';
|
|
49
|
+
|
|
50
|
+
if (!from || !body) {
|
|
51
|
+
return new Response('Missing From or Body', { status: 400 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const match = await findCustomerByPhone(from);
|
|
55
|
+
await db.insert(smsMessages).values({
|
|
56
|
+
customerId: match.customerId,
|
|
57
|
+
direction: 'inbound',
|
|
58
|
+
fromNumber: from,
|
|
59
|
+
toNumber: to,
|
|
60
|
+
body,
|
|
61
|
+
twilioMessageSid: messageSid,
|
|
62
|
+
status: 'received',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return new Response('<?xml version="1.0" encoding="UTF-8"?><Response/>', {
|
|
66
|
+
status: 200,
|
|
67
|
+
headers: { 'Content-Type': 'text/xml' },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
2
|
+
import { customers } from './customers';
|
|
3
|
+
|
|
4
|
+
export const smsDirection = pgEnum('sms_direction', ['inbound', 'outbound']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Two-way SMS log. Inbound messages land here via the Twilio webhook;
|
|
8
|
+
* outbound messages are inserted by the sendSms server action.
|
|
9
|
+
* customerId is nullable because inbound messages from unknown numbers
|
|
10
|
+
* still get logged (and can be matched to a customer later).
|
|
11
|
+
*/
|
|
12
|
+
export const smsMessages = pgTable('sms_messages', {
|
|
13
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
14
|
+
customerId: uuid('customer_id').references(() => customers.id, { onDelete: 'set null' }),
|
|
15
|
+
direction: smsDirection('direction').notNull(),
|
|
16
|
+
fromNumber: text('from_number').notNull(), // E.164
|
|
17
|
+
toNumber: text('to_number').notNull(), // E.164
|
|
18
|
+
body: text('body').notNull(),
|
|
19
|
+
/** Twilio MessageSid — useful for delivery-status webhook lookup later. */
|
|
20
|
+
twilioMessageSid: text('twilio_message_sid'),
|
|
21
|
+
status: text('status'), // queued / sent / delivered / failed / received
|
|
22
|
+
errorMessage: text('error_message'),
|
|
23
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type SmsMessageRow = typeof smsMessages.$inferSelect;
|
|
27
|
+
export type NewSmsMessage = typeof smsMessages.$inferInsert;
|