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,121 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { desc, eq } from 'drizzle-orm';
|
|
5
|
+
import { PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|
6
|
+
import { revalidatePath } from 'next/cache';
|
|
7
|
+
import { db } from '@/db/client';
|
|
8
|
+
import { jobAttachments } from '@/db/schema';
|
|
9
|
+
import { isR2Configured, publicUrlFor, r2BucketName, r2Client } from '@/lib/r2';
|
|
10
|
+
|
|
11
|
+
const MAX_BYTES = 8 * 1024 * 1024; // 8MB per file
|
|
12
|
+
const ALLOWED_TYPES = new Set([
|
|
13
|
+
'image/jpeg',
|
|
14
|
+
'image/png',
|
|
15
|
+
'image/webp',
|
|
16
|
+
'image/heic',
|
|
17
|
+
'image/heif',
|
|
18
|
+
'image/gif',
|
|
19
|
+
'application/pdf',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export interface JobAttachmentView {
|
|
23
|
+
id: string;
|
|
24
|
+
url: string;
|
|
25
|
+
kind: string;
|
|
26
|
+
contentType: string;
|
|
27
|
+
sizeBytes: number;
|
|
28
|
+
caption?: string;
|
|
29
|
+
uploadedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getJobAttachments(jobId: string): Promise<JobAttachmentView[]> {
|
|
33
|
+
const rows = await db
|
|
34
|
+
.select()
|
|
35
|
+
.from(jobAttachments)
|
|
36
|
+
.where(eq(jobAttachments.jobId, jobId))
|
|
37
|
+
.orderBy(desc(jobAttachments.uploadedAt));
|
|
38
|
+
return rows.map((r) => ({
|
|
39
|
+
id: r.id,
|
|
40
|
+
url: r.url,
|
|
41
|
+
kind: r.kind,
|
|
42
|
+
contentType: r.contentType,
|
|
43
|
+
sizeBytes: r.sizeBytes,
|
|
44
|
+
caption: r.caption ?? undefined,
|
|
45
|
+
uploadedAt: r.uploadedAt.toISOString(),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Server action — receives a file via multipart upload (FormData), pushes
|
|
51
|
+
* it to R2, and records the attachment in Postgres.
|
|
52
|
+
*
|
|
53
|
+
* Note: the file is buffered through Next.js. On Vercel the platform limit
|
|
54
|
+
* is ~4.5MB for serverless functions, configurable up to ~50MB on the
|
|
55
|
+
* Edge runtime. For larger files, swap this for the presigned-PUT pattern
|
|
56
|
+
* (client uploads directly to R2). See the Cloudflare R2 docs.
|
|
57
|
+
*/
|
|
58
|
+
export async function uploadJobAttachment(formData: FormData): Promise<void> {
|
|
59
|
+
if (!isR2Configured) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'R2 not configured. Set R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET (and optionally R2_PUBLIC_URL) in .env.local.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const jobId = (formData.get('jobId') ?? '').toString();
|
|
66
|
+
const caption = (formData.get('caption') ?? '').toString().trim() || null;
|
|
67
|
+
const file = formData.get('file');
|
|
68
|
+
if (!jobId) throw new Error('jobId is required');
|
|
69
|
+
if (!(file instanceof File) || file.size === 0) throw new Error('No file provided');
|
|
70
|
+
if (file.size > MAX_BYTES) {
|
|
71
|
+
throw new Error(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Max 8MB.`);
|
|
72
|
+
}
|
|
73
|
+
if (!ALLOWED_TYPES.has(file.type)) {
|
|
74
|
+
throw new Error(`Unsupported type: ${file.type}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ext = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() : null;
|
|
78
|
+
const safeExt = ext ? `.${ext.replace(/[^a-z0-9]/g, '')}` : '';
|
|
79
|
+
const key = `jobs/${jobId}/${crypto.randomBytes(8).toString('hex')}${safeExt}`;
|
|
80
|
+
|
|
81
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
82
|
+
await r2Client.send(
|
|
83
|
+
new PutObjectCommand({
|
|
84
|
+
Bucket: r2BucketName,
|
|
85
|
+
Key: key,
|
|
86
|
+
Body: bytes,
|
|
87
|
+
ContentType: file.type,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await db.insert(jobAttachments).values({
|
|
92
|
+
jobId,
|
|
93
|
+
key,
|
|
94
|
+
url: publicUrlFor(key),
|
|
95
|
+
kind: file.type.startsWith('image/') ? 'photo' : 'file',
|
|
96
|
+
contentType: file.type,
|
|
97
|
+
sizeBytes: file.size,
|
|
98
|
+
caption,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
revalidatePath(`/jobs/${jobId}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function deleteJobAttachment(attachmentId: string, jobId: string): Promise<void> {
|
|
105
|
+
if (!isR2Configured) throw new Error('R2 not configured');
|
|
106
|
+
const [row] = await db
|
|
107
|
+
.select()
|
|
108
|
+
.from(jobAttachments)
|
|
109
|
+
.where(eq(jobAttachments.id, attachmentId))
|
|
110
|
+
.limit(1);
|
|
111
|
+
if (!row) return;
|
|
112
|
+
// Best-effort R2 delete — even if it fails we still drop the DB row so
|
|
113
|
+
// the UI doesn't show a phantom thumbnail.
|
|
114
|
+
try {
|
|
115
|
+
await r2Client.send(new DeleteObjectCommand({ Bucket: r2BucketName, Key: row.key }));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('[r2] failed to delete object', row.key, err);
|
|
118
|
+
}
|
|
119
|
+
await db.delete(jobAttachments).where(eq(jobAttachments.id, attachmentId));
|
|
120
|
+
revalidatePath(`/jobs/${jobId}`);
|
|
121
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cloudflare R2 client. R2 is S3-compatible — same SDK, just point the
|
|
6
|
+
* endpoint at your R2 account. Configure:
|
|
7
|
+
*
|
|
8
|
+
* R2_ACCOUNT_ID — your Cloudflare account ID
|
|
9
|
+
* R2_ACCESS_KEY_ID — R2 API token access key
|
|
10
|
+
* R2_SECRET_ACCESS_KEY — R2 API token secret
|
|
11
|
+
* R2_BUCKET — bucket name (no slashes)
|
|
12
|
+
* R2_PUBLIC_URL — public URL for the bucket (custom domain or
|
|
13
|
+
* public-development URL). Required to render
|
|
14
|
+
* uploaded images in <img src> — uploads still
|
|
15
|
+
* work without it, but the URL field will be blank.
|
|
16
|
+
*/
|
|
17
|
+
export const r2BucketName = process.env.R2_BUCKET ?? '';
|
|
18
|
+
export const r2PublicUrl = (process.env.R2_PUBLIC_URL ?? '').replace(/\/+$/, '');
|
|
19
|
+
|
|
20
|
+
export const isR2Configured = (() => {
|
|
21
|
+
return Boolean(
|
|
22
|
+
process.env.R2_ACCOUNT_ID &&
|
|
23
|
+
process.env.R2_ACCESS_KEY_ID &&
|
|
24
|
+
process.env.R2_SECRET_ACCESS_KEY &&
|
|
25
|
+
process.env.R2_BUCKET,
|
|
26
|
+
);
|
|
27
|
+
})();
|
|
28
|
+
|
|
29
|
+
const accountId = process.env.R2_ACCOUNT_ID ?? '';
|
|
30
|
+
|
|
31
|
+
export const r2Client = new S3Client({
|
|
32
|
+
region: 'auto',
|
|
33
|
+
endpoint: accountId ? `https://${accountId}.r2.cloudflarestorage.com` : undefined,
|
|
34
|
+
credentials: {
|
|
35
|
+
// `||` not `??`: scaffolded .env.local ships these as "" (empty string),
|
|
36
|
+
// which `??` wouldn't replace.
|
|
37
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID || 'placeholder',
|
|
38
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || 'placeholder',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function publicUrlFor(key: string): string {
|
|
43
|
+
if (!r2PublicUrl) return '';
|
|
44
|
+
return `${r2PublicUrl}/${key}`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
import { Input } from '@/components/ui/input';
|
|
4
|
+
import { Label } from '@/components/ui/label';
|
|
5
|
+
|
|
6
|
+
const SERVICES = ['Service call', 'Maintenance', 'Repair', 'Install', 'Estimate'];
|
|
7
|
+
|
|
8
|
+
export default function BookOnlinePage() {
|
|
9
|
+
return (
|
|
10
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
11
|
+
<h1 className="text-3xl font-bold tracking-tight">Book online</h1>
|
|
12
|
+
<p className="text-muted-foreground mt-2">Pick a service, a date, and we'll confirm shortly.</p>
|
|
13
|
+
<Card className="mt-6">
|
|
14
|
+
<CardHeader>
|
|
15
|
+
<CardTitle>Request an appointment</CardTitle>
|
|
16
|
+
<CardDescription>This is a starter form — wire submission to your stack.</CardDescription>
|
|
17
|
+
</CardHeader>
|
|
18
|
+
<CardContent className="space-y-4">
|
|
19
|
+
<div className="space-y-2">
|
|
20
|
+
<Label htmlFor="service">Service</Label>
|
|
21
|
+
<select id="service" name="service" className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm">
|
|
22
|
+
{SERVICES.map((s) => <option key={s}>{s}</option>)}
|
|
23
|
+
</select>
|
|
24
|
+
</div>
|
|
25
|
+
<div className="grid grid-cols-2 gap-3">
|
|
26
|
+
<div className="space-y-2"><Label htmlFor="date">Preferred date</Label><Input id="date" type="date" /></div>
|
|
27
|
+
<div className="space-y-2"><Label htmlFor="time">Window</Label>
|
|
28
|
+
<select id="time" name="time" className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm">
|
|
29
|
+
<option>Morning (8am–12pm)</option>
|
|
30
|
+
<option>Afternoon (12–4pm)</option>
|
|
31
|
+
<option>Evening (4–8pm)</option>
|
|
32
|
+
</select>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="space-y-2"><Label htmlFor="name">Your name</Label><Input id="name" required /></div>
|
|
36
|
+
<div className="space-y-2"><Label htmlFor="phone">Phone</Label><Input id="phone" type="tel" required /></div>
|
|
37
|
+
<div className="space-y-2"><Label htmlFor="address">Service address</Label><Input id="address" required /></div>
|
|
38
|
+
<Button className="w-full" size="lg">Request appointment</Button>
|
|
39
|
+
</CardContent>
|
|
40
|
+
</Card>
|
|
41
|
+
</main>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
3
|
+
import { business } from '@/lib/business';
|
|
4
|
+
|
|
5
|
+
export default function BookThanksPage() {
|
|
6
|
+
return (
|
|
7
|
+
<main className="mx-auto max-w-md px-6 py-20 text-center">
|
|
8
|
+
<Card>
|
|
9
|
+
<CardContent className="pt-10 pb-10">
|
|
10
|
+
<div className="text-brand mb-4 text-5xl">✓</div>
|
|
11
|
+
<h1 className="text-2xl font-bold">Got it — talk soon!</h1>
|
|
12
|
+
<p className="text-muted-foreground mt-3 text-sm">
|
|
13
|
+
Thanks for booking with {business.name}. We'll reach out to confirm
|
|
14
|
+
your appointment shortly.
|
|
15
|
+
</p>
|
|
16
|
+
{business.phone && (
|
|
17
|
+
<p className="mt-4 text-sm">
|
|
18
|
+
Need to reach us first?{' '}
|
|
19
|
+
<a href={`tel:${business.phone}`} className="text-brand underline">
|
|
20
|
+
{business.phone}
|
|
21
|
+
</a>
|
|
22
|
+
</p>
|
|
23
|
+
)}
|
|
24
|
+
<Link href="/" className="text-muted-foreground mt-8 inline-block text-sm underline">
|
|
25
|
+
← Back to home
|
|
26
|
+
</Link>
|
|
27
|
+
</CardContent>
|
|
28
|
+
</Card>
|
|
29
|
+
</main>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
4
|
+
|
|
5
|
+
const SERVICES = [
|
|
6
|
+
{ title: 'Same-day service', body: 'Most calls handled the same day. Emergency service 24/7.' },
|
|
7
|
+
{ title: 'Up-front pricing', body: 'See the price before we start. No surprises.' },
|
|
8
|
+
{ title: 'Licensed & insured', body: 'Background-checked technicians and full warranty on work.' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const REVIEWS = [
|
|
12
|
+
{ name: 'Jamie R.', text: 'Showed up on time, fixed the AC in 30 minutes. Great experience.', stars: 5 },
|
|
13
|
+
{ name: 'Mason Hardware', text: 'Reliable for our walk-in cooler — they take our maintenance plan seriously.', stars: 5 },
|
|
14
|
+
{ name: 'Priya P.', text: 'Honest estimate, no upsell. Will use again for our water heater.', stars: 5 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export default function MarketingHome() {
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
<nav className="border-border border-b">
|
|
21
|
+
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
|
22
|
+
<Link href="/" className="text-lg font-bold">Your CRM</Link>
|
|
23
|
+
<div className="flex items-center gap-3">
|
|
24
|
+
<a href="tel:5550101100" className="text-muted-foreground text-sm hover:text-foreground">(555) 010-1100</a>
|
|
25
|
+
<Button asChild size="sm" variant="outline"><Link href="/sign-in">Sign in</Link></Button>
|
|
26
|
+
<Button asChild size="sm"><Link href="/book">Book online</Link></Button>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</nav>
|
|
30
|
+
|
|
31
|
+
<section className="bg-gradient-to-b from-brand/10 to-transparent">
|
|
32
|
+
<div className="mx-auto max-w-5xl px-6 py-20 text-center">
|
|
33
|
+
<h1 className="text-5xl font-bold tracking-tight sm:text-6xl">
|
|
34
|
+
Home services <span className="text-brand">done right</span>.
|
|
35
|
+
</h1>
|
|
36
|
+
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
|
37
|
+
Trusted by your neighbors for fast, transparent, professional service. Schedule online in under 60 seconds.
|
|
38
|
+
</p>
|
|
39
|
+
<div className="mt-8 flex justify-center gap-3">
|
|
40
|
+
<Button asChild size="lg"><Link href="/book">Book online</Link></Button>
|
|
41
|
+
<Button asChild size="lg" variant="outline"><a href="tel:5550101100">Call us</a></Button>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section className="mx-auto max-w-5xl px-6 py-16">
|
|
47
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
48
|
+
{SERVICES.map((s) => (
|
|
49
|
+
<Card key={s.title}>
|
|
50
|
+
<CardContent className="pt-6">
|
|
51
|
+
<h3 className="text-xl font-semibold">{s.title}</h3>
|
|
52
|
+
<p className="text-muted-foreground mt-2">{s.body}</p>
|
|
53
|
+
</CardContent>
|
|
54
|
+
</Card>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
<section className="bg-muted/30">
|
|
60
|
+
<div className="mx-auto max-w-5xl px-6 py-16">
|
|
61
|
+
<h2 className="text-center text-3xl font-bold tracking-tight">What customers say</h2>
|
|
62
|
+
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
63
|
+
{REVIEWS.map((r) => (
|
|
64
|
+
<Card key={r.name}>
|
|
65
|
+
<CardContent className="pt-6">
|
|
66
|
+
<div className="text-brand text-lg">{'★'.repeat(r.stars)}{'☆'.repeat(5 - r.stars)}</div>
|
|
67
|
+
<p className="mt-3">{r.text}</p>
|
|
68
|
+
<p className="text-muted-foreground mt-3 text-sm">— {r.name}</p>
|
|
69
|
+
</CardContent>
|
|
70
|
+
</Card>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<footer className="border-border border-t py-8 text-center">
|
|
77
|
+
<p className="text-muted-foreground text-sm">© Your CRM · Licensed & insured</p>
|
|
78
|
+
</footer>
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
import { Input } from '@/components/ui/input';
|
|
4
|
+
import { Label } from '@/components/ui/label';
|
|
5
|
+
import { business } from '@/lib/business';
|
|
6
|
+
import { submitBooking } from '@/lib/booking/actions';
|
|
7
|
+
|
|
8
|
+
const FALLBACK_SERVICES = ['Service call', 'Maintenance', 'Repair', 'Install', 'Estimate'];
|
|
9
|
+
|
|
10
|
+
export default function BookOnlinePage() {
|
|
11
|
+
// If the scaffolded business is an industry-specific preset, swap in its
|
|
12
|
+
// typed service list. (business.industry comes from src/lib/business.ts.)
|
|
13
|
+
const services = FALLBACK_SERVICES;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
17
|
+
<h1 className="text-3xl font-bold tracking-tight">Book {business.name}</h1>
|
|
18
|
+
<p className="text-muted-foreground mt-2">
|
|
19
|
+
Pick a service, a date, and we'll confirm shortly.
|
|
20
|
+
{business.phone && (
|
|
21
|
+
<>
|
|
22
|
+
{' '}Prefer to talk?{' '}
|
|
23
|
+
<a href={`tel:${business.phone}`} className="text-brand underline">
|
|
24
|
+
{business.phone}
|
|
25
|
+
</a>
|
|
26
|
+
</>
|
|
27
|
+
)}
|
|
28
|
+
</p>
|
|
29
|
+
<form action={submitBooking}>
|
|
30
|
+
<Card className="mt-6">
|
|
31
|
+
<CardHeader>
|
|
32
|
+
<CardTitle>Request an appointment</CardTitle>
|
|
33
|
+
<CardDescription>
|
|
34
|
+
We'll confirm via {business.email || 'email'} or text. No charge to book.
|
|
35
|
+
</CardDescription>
|
|
36
|
+
</CardHeader>
|
|
37
|
+
<CardContent className="space-y-4">
|
|
38
|
+
<div className="space-y-2">
|
|
39
|
+
<Label htmlFor="service">Service *</Label>
|
|
40
|
+
<select
|
|
41
|
+
id="service"
|
|
42
|
+
name="service"
|
|
43
|
+
required
|
|
44
|
+
className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
|
|
45
|
+
>
|
|
46
|
+
{services.map((s) => (
|
|
47
|
+
<option key={s} value={s}>{s}</option>
|
|
48
|
+
))}
|
|
49
|
+
</select>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="grid grid-cols-2 gap-3">
|
|
52
|
+
<div className="space-y-2">
|
|
53
|
+
<Label htmlFor="date">Preferred date</Label>
|
|
54
|
+
<Input id="date" name="date" type="date" />
|
|
55
|
+
</div>
|
|
56
|
+
<div className="space-y-2">
|
|
57
|
+
<Label htmlFor="time">Window</Label>
|
|
58
|
+
<select
|
|
59
|
+
id="time"
|
|
60
|
+
name="time"
|
|
61
|
+
className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
|
|
62
|
+
>
|
|
63
|
+
<option>Morning (8am–12pm)</option>
|
|
64
|
+
<option>Afternoon (12–4pm)</option>
|
|
65
|
+
<option>Evening (4–8pm)</option>
|
|
66
|
+
</select>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="space-y-2">
|
|
70
|
+
<Label htmlFor="name">Your name *</Label>
|
|
71
|
+
<Input id="name" name="name" required />
|
|
72
|
+
</div>
|
|
73
|
+
<div className="grid grid-cols-2 gap-3">
|
|
74
|
+
<div className="space-y-2">
|
|
75
|
+
<Label htmlFor="phone">Phone *</Label>
|
|
76
|
+
<Input id="phone" name="phone" type="tel" required />
|
|
77
|
+
</div>
|
|
78
|
+
<div className="space-y-2">
|
|
79
|
+
<Label htmlFor="email">Email</Label>
|
|
80
|
+
<Input id="email" name="email" type="email" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="space-y-2">
|
|
84
|
+
<Label htmlFor="address">Service address *</Label>
|
|
85
|
+
<Input id="address" name="address" required placeholder="120 Main St, Austin, TX" />
|
|
86
|
+
</div>
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
<Label htmlFor="notes">What can we help with? (optional)</Label>
|
|
89
|
+
<textarea
|
|
90
|
+
id="notes"
|
|
91
|
+
name="notes"
|
|
92
|
+
rows={3}
|
|
93
|
+
placeholder="Describe the issue, equipment make/model, anything we should know"
|
|
94
|
+
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"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
<Button type="submit" className="w-full" size="lg">
|
|
98
|
+
Request appointment
|
|
99
|
+
</Button>
|
|
100
|
+
</CardContent>
|
|
101
|
+
</Card>
|
|
102
|
+
</form>
|
|
103
|
+
</main>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { redirect } from 'next/navigation';
|
|
5
|
+
import { revalidatePath } from 'next/cache';
|
|
6
|
+
import { db } from '@/db/client';
|
|
7
|
+
import { customers, jobs } from '@/db/schema';
|
|
8
|
+
|
|
9
|
+
export interface BookingInput {
|
|
10
|
+
service: string;
|
|
11
|
+
preferredDate: string; // YYYY-MM-DD
|
|
12
|
+
timeWindow: string; // "Morning (8am–12pm)" etc.
|
|
13
|
+
name: string;
|
|
14
|
+
phone: string;
|
|
15
|
+
email?: string;
|
|
16
|
+
address: string;
|
|
17
|
+
notes?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Server action — receives a public booking request, creates the customer
|
|
22
|
+
* (or matches an existing one by phone), and creates a 'lead' job.
|
|
23
|
+
* Returns to /book/thanks on success.
|
|
24
|
+
*/
|
|
25
|
+
export async function submitBooking(formData: FormData): Promise<void> {
|
|
26
|
+
const get = (k: string) => (formData.get(k) ?? '').toString().trim();
|
|
27
|
+
|
|
28
|
+
const input: BookingInput = {
|
|
29
|
+
service: get('service'),
|
|
30
|
+
preferredDate: get('date'),
|
|
31
|
+
timeWindow: get('time'),
|
|
32
|
+
name: get('name'),
|
|
33
|
+
phone: get('phone'),
|
|
34
|
+
email: get('email') || undefined,
|
|
35
|
+
address: get('address'),
|
|
36
|
+
notes: get('notes') || undefined,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (!input.name || !input.phone || !input.address || !input.service) {
|
|
40
|
+
throw new Error('Missing required booking fields');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Address parsing is intentionally permissive — a single-line address
|
|
44
|
+
// entered on a public form rarely has clean city/state/zip split. We
|
|
45
|
+
// store the whole thing in line1 and leave the rest blank.
|
|
46
|
+
const billingAddress = {
|
|
47
|
+
line1: input.address,
|
|
48
|
+
city: '',
|
|
49
|
+
state: '',
|
|
50
|
+
postalCode: '',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const [customer] = await db
|
|
54
|
+
.insert(customers)
|
|
55
|
+
.values({
|
|
56
|
+
name: input.name,
|
|
57
|
+
phones: [input.phone],
|
|
58
|
+
emails: input.email ? [input.email] : [],
|
|
59
|
+
billingAddress,
|
|
60
|
+
serviceAddresses: [billingAddress],
|
|
61
|
+
tags: ['lead', 'online-booking'],
|
|
62
|
+
leadSource: 'Online booking',
|
|
63
|
+
lifetimeValue: 0,
|
|
64
|
+
balanceDue: 0,
|
|
65
|
+
doNotContact: false,
|
|
66
|
+
notes: input.notes ?? null,
|
|
67
|
+
publicToken: crypto.randomBytes(16).toString('hex'),
|
|
68
|
+
})
|
|
69
|
+
.returning({ id: customers.id });
|
|
70
|
+
|
|
71
|
+
// Schedule the job at the preferred date, defaulting to a 9-11am window
|
|
72
|
+
// if morning was picked. Office staff can refine in dispatch.
|
|
73
|
+
let scheduledAt: Date | null = null;
|
|
74
|
+
if (input.preferredDate) {
|
|
75
|
+
scheduledAt = new Date(input.preferredDate + 'T09:00:00');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await db.insert(jobs).values({
|
|
79
|
+
customerId: customer.id,
|
|
80
|
+
serviceType: input.service,
|
|
81
|
+
status: 'lead',
|
|
82
|
+
priority: 'normal',
|
|
83
|
+
scheduledAt,
|
|
84
|
+
arrivalWindow: input.timeWindow || null,
|
|
85
|
+
assigneeIds: [],
|
|
86
|
+
lineItems: [],
|
|
87
|
+
total: 0,
|
|
88
|
+
notes:
|
|
89
|
+
`Online booking request.\n` +
|
|
90
|
+
`Preferred: ${input.preferredDate || '(any)'} · ${input.timeWindow || '(any window)'}\n` +
|
|
91
|
+
(input.notes ? `\n${input.notes}` : ''),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
revalidatePath('/jobs');
|
|
95
|
+
revalidatePath('/customers');
|
|
96
|
+
redirect('/book/thanks');
|
|
97
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { Badge } from '@/components/ui/badge';
|
|
3
|
+
import { formatCurrency, formatDate } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
const PLANS = [
|
|
6
|
+
{ id: 'mp_silver', name: 'Silver', visits: 1, priceMonthly: 1499, perks: 'Annual tune-up + 10% off repairs' },
|
|
7
|
+
{ id: 'mp_gold', name: 'Gold', visits: 2, priceMonthly: 2499, perks: 'Bi-annual tune-ups + 15% off + priority booking' },
|
|
8
|
+
{ id: 'mp_platinum', name: 'Platinum', visits: 4, priceMonthly: 3999, perks: 'Quarterly visits + 20% off + after-hours included' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const MEMBERS = [
|
|
12
|
+
{ id: 'm1', customer: 'Acme Property Mgmt', plan: 'Platinum', renewsOn: '2026-12-01', mrr: 3999 },
|
|
13
|
+
{ id: 'm2', customer: 'Jamie Rodriguez', plan: 'Gold', renewsOn: '2026-08-15', mrr: 2499 },
|
|
14
|
+
{ id: 'm3', customer: 'Mason Hardware Co.', plan: 'Silver', renewsOn: '2026-11-22', mrr: 1499 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export default function MaintenancePlansPage() {
|
|
18
|
+
const totalMrr = MEMBERS.reduce((acc, m) => acc + m.mrr, 0);
|
|
19
|
+
return (
|
|
20
|
+
<div className="space-y-6">
|
|
21
|
+
<div className="flex items-center justify-between">
|
|
22
|
+
<div>
|
|
23
|
+
<h1 className="text-3xl font-bold tracking-tight">Maintenance plans</h1>
|
|
24
|
+
<p className="text-muted-foreground mt-1 text-sm">Recurring revenue from tune-up subscriptions.</p>
|
|
25
|
+
</div>
|
|
26
|
+
<Card className="px-4 py-2">
|
|
27
|
+
<div className="text-muted-foreground text-xs uppercase">Total MRR</div>
|
|
28
|
+
<div className="text-2xl font-bold">{formatCurrency(totalMrr)}</div>
|
|
29
|
+
</Card>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
32
|
+
{PLANS.map((p) => (
|
|
33
|
+
<Card key={p.id} className={p.name === 'Gold' ? 'border-brand' : ''}>
|
|
34
|
+
<CardHeader>
|
|
35
|
+
<CardTitle>{p.name}</CardTitle>
|
|
36
|
+
<p className="text-3xl font-bold">{formatCurrency(p.priceMonthly)}<span className="text-muted-foreground text-sm font-normal">/mo</span></p>
|
|
37
|
+
</CardHeader>
|
|
38
|
+
<CardContent className="space-y-2 text-sm">
|
|
39
|
+
<div><span className="font-medium">{p.visits}</span> visit{p.visits === 1 ? '' : 's'}/year</div>
|
|
40
|
+
<div className="text-muted-foreground">{p.perks}</div>
|
|
41
|
+
</CardContent>
|
|
42
|
+
</Card>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
<Card>
|
|
46
|
+
<CardHeader><CardTitle>Active members ({MEMBERS.length})</CardTitle></CardHeader>
|
|
47
|
+
<CardContent className="p-0">
|
|
48
|
+
<table className="w-full text-sm">
|
|
49
|
+
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
|
|
50
|
+
<tr>
|
|
51
|
+
<th className="px-4 py-3 text-left font-medium">Customer</th>
|
|
52
|
+
<th className="px-4 py-3 text-left font-medium">Plan</th>
|
|
53
|
+
<th className="px-4 py-3 text-left font-medium">Renews</th>
|
|
54
|
+
<th className="px-4 py-3 text-right font-medium">Monthly</th>
|
|
55
|
+
</tr>
|
|
56
|
+
</thead>
|
|
57
|
+
<tbody className="divide-border divide-y">
|
|
58
|
+
{MEMBERS.map((m) => (
|
|
59
|
+
<tr key={m.id}>
|
|
60
|
+
<td className="px-4 py-3 font-medium">{m.customer}</td>
|
|
61
|
+
<td className="px-4 py-3"><Badge>{m.plan}</Badge></td>
|
|
62
|
+
<td className="text-muted-foreground px-4 py-3">{formatDate(m.renewsOn)}</td>
|
|
63
|
+
<td className="px-4 py-3 text-right font-medium">{formatCurrency(m.mrr)}</td>
|
|
64
|
+
</tr>
|
|
65
|
+
))}
|
|
66
|
+
</tbody>
|
|
67
|
+
</table>
|
|
68
|
+
</CardContent>
|
|
69
|
+
</Card>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Field-tech iPhone app
|
|
2
|
+
|
|
3
|
+
A branded Expo (React Native) app for this client's technicians. It talks to
|
|
4
|
+
the deployed CRM's `/api/mobile/v1/*` endpoints with a bearer token, so the
|
|
5
|
+
**web CRM must be deployed and reachable first**.
|
|
6
|
+
|
|
7
|
+
What techs can do: sign in, see today's jobs, open a job (customer, address,
|
|
8
|
+
line items), tap **Navigate** / **Call**, **Start**/**Complete** a job, snap &
|
|
9
|
+
upload **photos**, capture a **signature**, and receive **push notifications**
|
|
10
|
+
when a job is assigned. Plus: **clock in/out** per job, run the job
|
|
11
|
+
**checklist**, view & **invoice** the job and **take payment** (card via the
|
|
12
|
+
hosted page, or record cash), build & send an **estimate** from the price
|
|
13
|
+
book, and a two-way **SMS inbox**. Money/estimate/SMS features appear only if
|
|
14
|
+
the CRM was scaffolded with those modules (see `app.features.ts`).
|
|
15
|
+
|
|
16
|
+
## 1. Point the app at this client's CRM
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cd mobile
|
|
20
|
+
cp .env.example .env # if not already created by the scaffolder
|
|
21
|
+
# set EXPO_PUBLIC_API_URL to the client's deployed CRM URL, e.g.
|
|
22
|
+
# EXPO_PUBLIC_API_URL=https://joeshvac.com
|
|
23
|
+
npm install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For local testing against the web app on your machine, use your computer's
|
|
27
|
+
LAN IP (not `localhost`) so the phone can reach it:
|
|
28
|
+
`EXPO_PUBLIC_API_URL=http://192.168.1.50:3000`.
|
|
29
|
+
|
|
30
|
+
## 2. Run it in development
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx expo start # scan the QR code with Expo Go (or a dev build)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Sign in as a user with the **technician** (or admin/dispatcher) role. Photos
|
|
37
|
+
and signatures require the CRM's R2 storage env vars to be set.
|
|
38
|
+
|
|
39
|
+
## 3. Build & ship to the App Store (per client)
|
|
40
|
+
|
|
41
|
+
These apps are distributed **one per client** under your agency's single
|
|
42
|
+
Apple Developer account (one account hosts unlimited apps). Bundle id
|
|
43
|
+
convention: `com.<agency>.<client-slug>` (set in `app.brand.ts`).
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm i -g eas-cli
|
|
47
|
+
eas login # your agency Apple/Expo account
|
|
48
|
+
eas build:configure
|
|
49
|
+
eas build -p ios --profile production
|
|
50
|
+
eas submit -p ios --latest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Per-client effort is ~30–60 min of config (bundle id, icon, API URL) plus
|
|
54
|
+
Apple review (~1–2 days). For pilots, use TestFlight (`--profile preview`)
|
|
55
|
+
to skip full App Store review.
|
|
56
|
+
|
|
57
|
+
## Files
|
|
58
|
+
- `app.brand.ts` — generated branding (name, colors, bundle id, scheme). Re-run the scaffolder to change.
|
|
59
|
+
- `app.config.ts` — Expo config; reads `app.brand.ts`.
|
|
60
|
+
- `lib/auth.ts` — bearer-token sign-in + keychain storage (expo-secure-store).
|
|
61
|
+
- `lib/api.ts` — typed client for `/api/mobile/v1/*` + direct-to-R2 photo upload.
|
|
62
|
+
- `lib/push.ts` — Expo push registration.
|
|
63
|
+
- `app/` — Expo Router screens (sign-in, Today, job detail).
|
|
64
|
+
|
|
65
|
+
> The server side of this app lives in the CRM under `src/app/api/mobile/v1/`
|
|
66
|
+
> and token auth is enabled by the Better-Auth `bearer()` plugin, added
|
|
67
|
+
> automatically when you scaffolded with the iPhone app.
|