perf: optimize admin pages for <50ms TTFB
- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms) - Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts) - Auth fast-path: short-circuit Neon Auth DNS calls when not configured - PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking - Stale build artifacts fix (clean rebuild restores fast behavior) Measured (production build, sequential requests, dev_session cookie): - 102/103 pages: TTFB ≤ 10ms - 1 page: TTFB 11-20ms - 0 pages exceed 50ms TTFB - First Paint (browser): 28-84ms on admin pages
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
|
||||
> route-commerce-platform@2.0.0 build
|
||||
> next build --webpack
|
||||
|
||||
Warning: Custom Cache-Control headers detected for the following routes:
|
||||
- /_next/static/:path*
|
||||
|
||||
Setting a custom Cache-Control header can break Next.js development behavior.
|
||||
▲ Next.js 16.2.9 (webpack)
|
||||
- Environments: .env.local
|
||||
- Experiments (use with caution):
|
||||
· optimizePackageImports
|
||||
✓ viewTransition
|
||||
|
||||
Creating an optimized production build ...
|
||||
✓ Compiled successfully in 11.5s
|
||||
Running TypeScript ...
|
||||
Finished TypeScript in 14.8s ...
|
||||
Collecting page data using 19 workers ...
|
||||
Generating static pages using 19 workers (0/94) ...
|
||||
Generating static pages using 19 workers (23/94)
|
||||
Generating static pages using 19 workers (46/94)
|
||||
Generating static pages using 19 workers (70/94)
|
||||
✓ Generating static pages using 19 workers (94/94) in 416ms
|
||||
Finalizing page optimization ...
|
||||
Collecting build traces ...
|
||||
|
||||
Route (app) Revalidate Expire
|
||||
┌ ○ /
|
||||
├ ○ /_not-found
|
||||
├ ƒ /admin
|
||||
├ ƒ /admin/advanced
|
||||
├ ƒ /admin/analytics
|
||||
├ ƒ /admin/communications
|
||||
├ ƒ /admin/communications/abandoned-carts
|
||||
├ ƒ /admin/communications/analytics
|
||||
├ ƒ /admin/communications/campaigns/[id]
|
||||
├ ƒ /admin/communications/compose
|
||||
├ ƒ /admin/communications/contacts
|
||||
├ ƒ /admin/communications/logs
|
||||
├ ƒ /admin/communications/segments
|
||||
├ ƒ /admin/communications/settings
|
||||
├ ƒ /admin/communications/templates
|
||||
├ ƒ /admin/communications/templates/[id]
|
||||
├ ƒ /admin/communications/welcome-sequence
|
||||
├ ƒ /admin/import
|
||||
├ ƒ /admin/launch-checklist
|
||||
├ ƒ /admin/me
|
||||
├ ƒ /admin/orders
|
||||
├ ƒ /admin/orders/[id]
|
||||
├ ƒ /admin/orders/new
|
||||
├ ƒ /admin/pickup
|
||||
├ ƒ /admin/products
|
||||
├ ƒ /admin/products/[id]
|
||||
├ ƒ /admin/products/import
|
||||
├ ƒ /admin/products/new
|
||||
├ ƒ /admin/reports
|
||||
├ ƒ /admin/route-trace
|
||||
├ ƒ /admin/route-trace/lookup
|
||||
├ ƒ /admin/route-trace/lots
|
||||
├ ƒ /admin/route-trace/lots/[id]
|
||||
├ ƒ /admin/route-trace/lots/new
|
||||
├ ƒ /admin/route-trace/settings
|
||||
├ ƒ /admin/sales/import
|
||||
├ ƒ /admin/settings
|
||||
├ ƒ /admin/settings/ai
|
||||
├ ƒ /admin/settings/apps
|
||||
├ ƒ /admin/settings/billing
|
||||
├ ƒ /admin/settings/brand
|
||||
├ ƒ /admin/settings/integrations
|
||||
├ ƒ /admin/settings/payments
|
||||
├ ƒ /admin/settings/shipping
|
||||
├ ƒ /admin/settings/square-sync
|
||||
├ ƒ /admin/shipping
|
||||
├ ƒ /admin/stops
|
||||
├ ƒ /admin/stops/[id]
|
||||
├ ƒ /admin/stops/new
|
||||
├ ƒ /admin/taxes
|
||||
├ ƒ /admin/time-tracking
|
||||
├ ƒ /admin/time-tracking/settings
|
||||
├ ƒ /admin/users
|
||||
├ ƒ /admin/v2
|
||||
├ ƒ /admin/v2/orders
|
||||
├ ƒ /admin/v2/orders/[id]
|
||||
├ ƒ /admin/v2/products
|
||||
├ ƒ /admin/v2/stops
|
||||
├ ƒ /admin/water-log
|
||||
├ ƒ /admin/water-log/entries/[id]
|
||||
├ ƒ /admin/water-log/headgates
|
||||
├ ƒ /admin/water-log/headgates/[id]
|
||||
├ ƒ /admin/water-log/settings
|
||||
├ ƒ /admin/water-log/users/[id]
|
||||
├ ƒ /admin/wholesale
|
||||
├ ƒ /api/ai/campaign-writer
|
||||
├ ƒ /api/ai/customer-insights
|
||||
├ ƒ /api/ai/demand-forecast
|
||||
├ ƒ /api/ai/pricing-advisor
|
||||
├ ƒ /api/ai/product-writer
|
||||
├ ƒ /api/ai/report-explainer
|
||||
├ ƒ /api/ai/route-optimizer
|
||||
├ ƒ /api/ai/stop-blast-advisor
|
||||
├ ƒ /api/auth/[...nextauth]
|
||||
├ ƒ /api/auth/change-password
|
||||
├ ƒ /api/auth/forgot-password
|
||||
├ ƒ /api/auth/reset-password
|
||||
├ ƒ /api/auth/sign-in
|
||||
├ ƒ /api/auth/sign-out
|
||||
├ ƒ /api/cron/send-scheduled
|
||||
├ ƒ /api/email-automation/abandoned-cart
|
||||
├ ƒ /api/email-automation/welcome-sequence
|
||||
├ ƒ /api/forgot-password
|
||||
├ ƒ /api/health/db-schema
|
||||
├ ƒ /api/indian-river-direct/schedule-pdf
|
||||
├ ƒ /api/integrations/ai-provider
|
||||
├ ƒ /api/integrations/ai-provider/test
|
||||
├ ƒ /api/referrals
|
||||
├ ƒ /api/reports/export
|
||||
├ ƒ /api/resend/webhook
|
||||
├ ƒ /api/route-trace/fsma-compliance
|
||||
├ ƒ /api/route-trace/fsma-report
|
||||
├ ƒ /api/route-trace/sticker-pdf
|
||||
├ ƒ /api/route-trace/trace-report
|
||||
├ ƒ /api/square/oauth
|
||||
├ ƒ /api/square/oauth/callback
|
||||
├ ƒ /api/square/oauth/complete
|
||||
├ ƒ /api/square/process-queue
|
||||
├ ƒ /api/square/sync
|
||||
├ ƒ /api/stops/import
|
||||
├ ƒ /api/stripe/oauth
|
||||
├ ƒ /api/stripe/oauth/callback
|
||||
├ ƒ /api/stripe/oauth/complete
|
||||
├ ƒ /api/stripe/webhook
|
||||
├ ƒ /api/time-tracking/export
|
||||
├ ƒ /api/time-tracking/notify
|
||||
├ ƒ /api/tuxedo/schedule-pdf
|
||||
├ ƒ /api/v1/campaigns
|
||||
├ ƒ /api/v1/products
|
||||
├ ƒ /api/v1/referrals
|
||||
├ ƒ /api/v1/reports
|
||||
├ ƒ /api/v1/water-logs
|
||||
├ ƒ /api/waitlist
|
||||
├ ƒ /api/water-admin-auth
|
||||
├ ƒ /api/water-logs/export
|
||||
├ ƒ /api/water-photo-upload
|
||||
├ ƒ /api/water-qr
|
||||
├ ƒ /api/water-qr-label
|
||||
├ ƒ /api/water-qr-sheet
|
||||
├ ƒ /api/wholesale/checkout
|
||||
├ ƒ /api/wholesale/invoice/[orderId]
|
||||
├ ƒ /api/wholesale/invoice/[orderId]/pdf
|
||||
├ ƒ /api/wholesale/manifest
|
||||
├ ƒ /api/wholesale/notifications/pickup-reminder
|
||||
├ ƒ /api/wholesale/notifications/send
|
||||
├ ƒ /api/wholesale/price-sheet
|
||||
├ ƒ /api/wholesale/webhooks/dispatch
|
||||
├ ○ /blog
|
||||
├ ○ /brands
|
||||
├ ○ /cart
|
||||
├ ○ /change-password
|
||||
├ ○ /changelog
|
||||
├ ○ /checkout
|
||||
├ ○ /checkout/success
|
||||
├ ○ /contact
|
||||
├ ○ /indian-river-direct
|
||||
├ ○ /indian-river-direct/about
|
||||
├ ○ /indian-river-direct/stops 5m 1y
|
||||
├ ƒ /indian-river-direct/stops/[id]
|
||||
├ ○ /ird/time-clock
|
||||
├ ƒ /login
|
||||
├ ƒ /logout
|
||||
├ ○ /maintenance
|
||||
├ ○ /pricing
|
||||
├ ○ /privacy-policy
|
||||
├ ƒ /protected-example
|
||||
├ ○ /reset-password
|
||||
├ ○ /roadmap
|
||||
├ ○ /robots.txt
|
||||
├ ○ /security
|
||||
├ ○ /sitemap.xml
|
||||
├ ○ /terms-and-conditions
|
||||
├ ƒ /test
|
||||
├ ƒ /trace/[lotNumber]
|
||||
├ ○ /tuxedo
|
||||
├ ○ /tuxedo/about
|
||||
├ ○ /tuxedo/faq
|
||||
├ ○ /tuxedo/products/sweet-corn-box
|
||||
├ ○ /tuxedo/stops 5m 1y
|
||||
├ ƒ /tuxedo/stops/[id]
|
||||
├ ○ /tuxedo/time-clock
|
||||
├ ○ /waitlist
|
||||
├ ○ /water
|
||||
├ ƒ /water/admin
|
||||
├ ƒ /water/admin/login
|
||||
├ ƒ /wholesale/employee
|
||||
├ ○ /wholesale/login
|
||||
├ ○ /wholesale/payment/cancel
|
||||
├ ○ /wholesale/payment/success
|
||||
├ ƒ /wholesale/portal
|
||||
└ ○ /wholesale/register
|
||||
|
||||
|
||||
ƒ Proxy (Middleware)
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
ƒ (Dynamic) server-rendered on demand
|
||||
|
||||
@@ -0,0 +1,732 @@
|
||||
|
||||
> route-commerce-platform@2.0.0 lint
|
||||
> eslint
|
||||
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/brands.ts
|
||||
12:3 warning 'index' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/customers.ts
|
||||
10:3 warning 'varchar' is defined but never used @typescript-eslint/no-unused-vars
|
||||
11:3 warning 'bigint' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/marketing.ts
|
||||
52:52 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
/home/tyler/dev/routecomm/db/schema/water-log.ts
|
||||
27:3 warning 'check' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/playwright.config.ts
|
||||
2:8 warning 'path' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/admin/password.ts
|
||||
17:16 warning 'updatePasswordAction' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/ai-import.ts
|
||||
126:3 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
254:9 warning 'keywordMaps' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/billing/stripe-checkout.ts
|
||||
68:9 warning 'recurring' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/communications/contacts.ts
|
||||
107:9 warning 'fullName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
411:16 warning 'optOutContact' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/communications/import-contacts.ts
|
||||
5:21 warning 'withPlatformAdmin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
10:3 warning 'previewContactImport' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/communications/segments.ts
|
||||
70:16 warning 'saveSegments' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/dashboard.ts
|
||||
240:16 warning 'getDashboardSummary' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/email-automation/abandoned-cart.ts
|
||||
254:16 warning 'markCartRecovered' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/email-automation/welcome-sequence.ts
|
||||
184:16 warning 'sendWelcomeEmail' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/harvest-reach/campaigns.ts
|
||||
78:16 warning 'getHarvestReachCampaigns' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/import-orders.ts
|
||||
5:10 warning 'orders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
5:18 warning 'orderItems' is defined but never used @typescript-eslint/no-unused-vars
|
||||
5:30 warning 'customers' is defined but never used @typescript-eslint/no-unused-vars
|
||||
6:10 warning 'eq' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/integrations/ai-providers.ts
|
||||
199:16 warning 'getCustomIntegrations' is defined but never used @typescript-eslint/no-unused-vars
|
||||
212:16 warning 'upsertCustomIntegration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
233:16 warning 'deleteCustomIntegration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/integrations/credentials.ts
|
||||
178:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
210:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/orders.ts
|
||||
139:16 warning 'getAdminStops' is defined but never used @typescript-eslint/no-unused-vars
|
||||
146:16 warning 'getAdminPendingOrders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
153:16 warning 'getAdminPickedUpOrders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
184:16 warning 'toggleOrderPickupComplete' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/route-trace/lots.ts
|
||||
203:58 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
210:46 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
237:3 warning '_data' is defined but never used @typescript-eslint/no-unused-vars
|
||||
246:3 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
247:3 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
248:3 warning '_location' is defined but never used @typescript-eslint/no-unused-vars
|
||||
249:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
250:3 warning '_binId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
276:58 warning '_query' is defined but never used @typescript-eslint/no-unused-vars
|
||||
283:37 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
304:36 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
328:3 warning '_lotId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
329:3 warning '_orderId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
330:3 warning '_quantityToAdd' is defined but never used @typescript-eslint/no-unused-vars
|
||||
331:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
341:3 warning '_limit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
359:40 warning '_lotNumber' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/shipping.ts
|
||||
20:3 warning '_orderId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:3 warning '_status' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:3 warning '_trackingNumber' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/shipping/fedex-labels.ts
|
||||
20:43 warning 'clearFedExToken' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/shipping/fedex-rates.ts
|
||||
104:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/square-sync-ui.ts
|
||||
90:16 warning 'getSquareQueueCount' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/storefront.ts
|
||||
130:16 warning 'getStorefrontData' is defined but never used @typescript-eslint/no-unused-vars
|
||||
154:16 warning 'getStorefrontWholesaleSettings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
181:16 warning 'getWholesalePortalConfig' is defined but never used @typescript-eslint/no-unused-vars
|
||||
202:16 warning 'getStorefrontBrandName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/tax.ts
|
||||
112:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/field.ts
|
||||
32:7 warning 'COOKIE_MAX_AGE' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
34:10 warning 'sessionCookie' is defined but never used @typescript-eslint/no-unused-vars
|
||||
57:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
58:3 warning '_pin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
71:3 warning '_taskName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
83:3 warning '_lunchMinutes' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
84:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
96:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
146:3 warning '_activeOnly' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
187:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/index.ts
|
||||
67:46 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
76:3 warning '_role' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
77:3 warning '_lang' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
85:42 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
93:3 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
94:3 warning '_name' is defined but never used @typescript-eslint/no-unused-vars
|
||||
95:3 warning '_role' is defined but never used @typescript-eslint/no-unused-vars
|
||||
96:3 warning '_lang' is defined but never used @typescript-eslint/no-unused-vars
|
||||
97:3 warning '_active' is defined but never used @typescript-eslint/no-unused-vars
|
||||
105:40 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
114:62 warning '_activeOnly' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
122:3 warning '_nameEs' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
123:3 warning '_unit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
124:3 warning '_sortOrder' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
133:3 warning '_taskId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
134:3 warning '_name' is defined but never used @typescript-eslint/no-unused-vars
|
||||
135:3 warning '_nameEs' is defined but never used @typescript-eslint/no-unused-vars
|
||||
136:3 warning '_unit' is defined but never used @typescript-eslint/no-unused-vars
|
||||
137:3 warning '_active' is defined but never used @typescript-eslint/no-unused-vars
|
||||
138:3 warning '_sortOrder' is defined but never used @typescript-eslint/no-unused-vars
|
||||
146:38 warning '_taskId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
157:3 warning '_options' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
170:16 warning 'updateWorkerTimeLog' is defined but never used @typescript-eslint/no-unused-vars
|
||||
171:3 warning '_logId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
172:3 warning '_taskName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
173:3 warning '_clockIn' is defined but never used @typescript-eslint/no-unused-vars
|
||||
174:3 warning '_clockOut' is defined but never used @typescript-eslint/no-unused-vars
|
||||
175:3 warning '_lunchMinutes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
176:3 warning '_notes' is defined but never used @typescript-eslint/no-unused-vars
|
||||
184:16 warning 'deleteWorkerTimeLog' is defined but never used @typescript-eslint/no-unused-vars
|
||||
184:36 warning '_logId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
192:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
193:3 warning '_start' is defined but never used @typescript-eslint/no-unused-vars
|
||||
194:3 warning '_end' is defined but never used @typescript-eslint/no-unused-vars
|
||||
221:47 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
228:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
229:3 warning '_settings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
271:3 warning '_limit' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/time-tracking/notifications.ts
|
||||
21:3 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:3 warning '_workerId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
23:3 warning '_workerName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:3 warning '_dailyHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
25:3 warning '_weeklyHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/water-log/admin.ts
|
||||
20:25 warning 'gte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:30 warning 'lte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:40 warning 'SQL' is defined but never used @typescript-eslint/no-unused-vars
|
||||
32:32 warning 'verifyPin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
33:25 warning 'logAlert' is defined but never used @typescript-eslint/no-unused-vars
|
||||
128:16 warning 'requireWaterAdminSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
164:3 warning Unused eslint-disable directive (no problems were reported from 'no-var')
|
||||
202:10 warning 'mapEntry' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/water-log/field.ts
|
||||
22:15 warning 'desc' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:25 warning 'gte' is defined but never used @typescript-eslint/no-unused-vars
|
||||
22:30 warning 'sql' is defined but never used @typescript-eslint/no-unused-vars
|
||||
186:3 warning '_headgateLocked' is defined but never used @typescript-eslint/no-unused-vars
|
||||
325:16 warning 'getFieldSessionUser' is defined but never used @typescript-eslint/no-unused-vars
|
||||
388:16 warning 'getWaterSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale-auth.ts
|
||||
21:44 warning '_formData' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale-register.ts
|
||||
281:12 warning '_' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/actions/wholesale.ts
|
||||
281:12 warning '_' is defined but never used @typescript-eslint/no-unused-vars
|
||||
651:12 warning '_' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/communications/abandoned-carts/page.tsx
|
||||
8:7 warning 'IRD_BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/import/ImportCenterClient.tsx
|
||||
154:9 warning 'activeBrandName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/launch-checklist/page.tsx
|
||||
3:32 warning 'ExternalLink' is defined but never used @typescript-eslint/no-unused-vars
|
||||
3:46 warning 'AlertCircle' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/me/page.tsx
|
||||
5:10 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/sales/import/page.tsx
|
||||
76:9 warning 'SAMPLE_CSV' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/settings/billing/AddPaymentMethodButton.tsx
|
||||
4:10 warning 'createAddonCheckoutSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/settings/integrations/page.tsx
|
||||
2:10 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/[id]/page.tsx
|
||||
49:11 warning 'ProductStop' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/new/page.tsx
|
||||
3:8 warning 'StopProductAssignment' is defined but never used @typescript-eslint/no-unused-vars
|
||||
67:17 warning 'productRows' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/stops/page.tsx
|
||||
21:10 warning 'params' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/water-log/headgates/page.tsx
|
||||
3:34 warning 'regenerateHeadgateToken' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/water-log/page.tsx
|
||||
12:10 warning 'WaterLogLoadingSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/admin/wholesale/DashboardTab.tsx
|
||||
17:12 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
18:10 warning '_onMsg' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/campaign-writer/route.ts
|
||||
61:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/customer-insights/route.ts
|
||||
140:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/demand-forecast/route.ts
|
||||
79:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/pricing-advisor/route.ts
|
||||
79:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/product-writer/route.ts
|
||||
74:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/report-explainer/route.ts
|
||||
84:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/route-optimizer/route.ts
|
||||
83:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/ai/stop-blast-advisor/route.ts
|
||||
15:22 warning 'stopId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
85:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/auth/sign-in/route.ts
|
||||
13:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/oauth/complete/route.ts
|
||||
4:10 warning 'escapeAttr' is defined but never used @typescript-eslint/no-unused-vars
|
||||
107:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/oauth/route.ts
|
||||
2:10 warning 'cookies' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/square/process-queue/route.ts
|
||||
61:9 warning 'lastError' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/stops/import/route.ts
|
||||
1:10 warning 'NextRequest' is defined but never used @typescript-eslint/no-unused-vars
|
||||
16:42 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/stripe/oauth/complete/route.ts
|
||||
89:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/time-tracking/export/route.ts
|
||||
75:9 warning 'includeNotes' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
127:11 warning 'weeklyThreshold' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
141:36 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
142:13 warning 'worker' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
144:15 warning 'regularHours' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
255:15 warning 'regularHours' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/time-tracking/notify/route.ts
|
||||
7:7 warning 'IRD_BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/tuxedo/schedule-pdf/route.ts
|
||||
5:6 warning 'Settings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-photo-upload/route.ts
|
||||
16:11 warning 'bucket' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
43:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr-label/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
9:35 warning 'token' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr-sheet/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
8:35 warning 'token' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/water-qr/route.ts
|
||||
1:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/wholesale/invoice/[orderId]/route.ts
|
||||
147:12 warning 'rightAlign' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/api/wholesale/manifest/route.ts
|
||||
27:9 warning 'effectiveBrandId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
37:9 warning 'rows' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/change-password/page.tsx
|
||||
6:10 warning 'getCurrentUserId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/error.tsx
|
||||
21:5 warning Unused eslint-disable directive (no problems were reported from 'no-console')
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/contact/layout.tsx
|
||||
42:52 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/faq/layout.tsx
|
||||
83:54 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/indian-river-direct/stops/[id]/page.tsx
|
||||
8:8 warning 'StopSetEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
51:21 warning 'setBrandSlug' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
52:23 warning 'setBrandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/page.tsx
|
||||
1:25 warning 'Viewport' is defined but never used @typescript-eslint/no-unused-vars
|
||||
123:9 warning Unused eslint-disable directive (no problems were reported from 'react/no-danger')
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/roadmap/page.tsx
|
||||
64:7 warning 'CATEGORIES' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/trace/[lotNumber]/page.tsx
|
||||
83:9 warning 'progressPercent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/about/AboutClient.tsx
|
||||
3:26 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/contact/ContactClientPage.tsx
|
||||
4:8 warning 'Link' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/contact/layout.tsx
|
||||
42:47 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/faq/layout.tsx
|
||||
132:43 warning 'children' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/page.tsx
|
||||
23:10 warning 'scrollToProducts' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/tuxedo/stops/[id]/page.tsx
|
||||
7:8 warning 'StopSetEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
50:21 warning 'setBrandSlug' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
51:23 warning 'setBrandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/water/admin/login/page.tsx
|
||||
1:10 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/employee/EmployeePortalClient.tsx
|
||||
435:10 warning 'EmployeePortalSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/portal/page.tsx
|
||||
94:5 error 'userId' is never reassigned. Use 'const' instead prefer-const
|
||||
|
||||
/home/tyler/dev/routecomm/src/app/wholesale/register/page.tsx
|
||||
268:9 warning 'router' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/auth.config.ts
|
||||
66:10 warning 'getNeonAuthConfigOptional' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/AnalyticsDashboard.tsx
|
||||
82:30 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
142:37 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
197:35 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
287:28 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/BrandSettingsForm.tsx
|
||||
43:13 warning '_initialSettings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
47:20 warning '_isPlatformAdmin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CampaignListPanel.tsx
|
||||
11:26 warning 'deleteCampaign' is defined but never used @typescript-eslint/no-unused-vars
|
||||
12:10 warning 'getCommunicationTemplates' is defined but never used @typescript-eslint/no-unused-vars
|
||||
344:21 warning 'setCampaigns' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/CardList.tsx
|
||||
23:57 warning 'as' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ContactImportForm.tsx
|
||||
85:10 warning 'getStatColor' is defined but never used @typescript-eslint/no-unused-vars
|
||||
91:10 warning 'getStatValueColor' is defined but never used @typescript-eslint/no-unused-vars
|
||||
463:32 warning 'field' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/EditStopModal.tsx
|
||||
608:10 warning 'useEditStopModal' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/AnalyticsDashboard.tsx
|
||||
109:10 warning 'EngagementBadge' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/CampaignComposerPage.tsx
|
||||
6:24 warning 'SegmentRuleV2' is defined but never used @typescript-eslint/no-unused-vars
|
||||
230:15 warning 'isUpcoming' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx
|
||||
2:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/LoadingState.tsx
|
||||
48:11 warning Unused eslint-disable directive (no problems were reported from 'react/no-array-index-key')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/NewStopForm.tsx
|
||||
7:10 warning 'AdminInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
7:23 warning 'AdminTextInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
7:39 warning 'AdminSelect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/OrderEditForm.tsx
|
||||
175:9 warning 'total' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ReportsDashboard.tsx
|
||||
337:11 warning 'brandId' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/SettingsClient.tsx
|
||||
2:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
4:20 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/SettingsSections.tsx
|
||||
21:10 warning 'AdminInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:23 warning 'AdminTextInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:39 warning 'AdminSelect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ShippingSettingsForm.tsx
|
||||
160:13 warning '_initialSettings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
163:20 warning '_isPlatformAdmin' is defined but never used @typescript-eslint/no-unused-vars
|
||||
180:25 error Error: Cannot access refs during render
|
||||
|
||||
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ShippingSettingsForm.tsx:180:25
|
||||
178 | // stale "loaded" UI between the prop change and the effect running.
|
||||
179 | const lastFetchedBrandIdRef = useRef<string | null>(null);
|
||||
> 180 | if (activeBrandId !== lastFetchedBrandIdRef.current) {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Cannot access ref value during render
|
||||
181 | lastFetchedBrandIdRef.current = activeBrandId;
|
||||
182 | setLoadData({ settings: null, loading: true });
|
||||
183 | } react-hooks/refs
|
||||
181:5 error Error: Cannot access refs during render
|
||||
|
||||
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ShippingSettingsForm.tsx:181:5
|
||||
179 | const lastFetchedBrandIdRef = useRef<string | null>(null);
|
||||
180 | if (activeBrandId !== lastFetchedBrandIdRef.current) {
|
||||
> 181 | lastFetchedBrandIdRef.current = activeBrandId;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Cannot update ref during render
|
||||
182 | setLoadData({ settings: null, loading: true });
|
||||
183 | }
|
||||
184 | react-hooks/refs
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopProductAssignment.tsx
|
||||
89:3 warning 'callerUid' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/StopTableClient.tsx
|
||||
781:3 warning 'isLoading' is defined but never used @typescript-eslint/no-unused-vars
|
||||
914:3 warning 'onDelete' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/TimeTrackingAdminPanel.tsx
|
||||
127:10 warning 'formatHours' is defined but never used @typescript-eslint/no-unused-vars
|
||||
707:47 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
707:56 warning 'onSave' is defined but never used @typescript-eslint/no-unused-vars
|
||||
760:43 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
760:52 warning 'onSave' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/TimeTrackingSettingsClient.tsx
|
||||
2:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/Toast.tsx
|
||||
88:10 warning 'useToastActions' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/ToastContainer.tsx
|
||||
4:10 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
||||
122:10 warning 'InlineToast' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/UpgradePlanModal.tsx
|
||||
78:3 warning 'isOpen' is defined but never used @typescript-eslint/no-unused-vars
|
||||
216:19 warning 'canUpgrade' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/UsersPage.tsx
|
||||
4:24 warning 'UpdateAdminUserInput' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/command-palette-data.ts
|
||||
389:7 warning 'PALETTE_ICON_NAMES' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminActionMenu.tsx
|
||||
25:52 warning 'triggerLabel' is defined but never used @typescript-eslint/no-unused-vars
|
||||
91:10 warning 'AdminActionButton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminCard.tsx
|
||||
31:10 warning 'AdminCardHeader' is defined but never used @typescript-eslint/no-unused-vars
|
||||
44:10 warning 'AdminCardFooter' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminDeleteConfirm.tsx
|
||||
73:10 warning 'useDeleteConfirm' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminFilterTabs.tsx
|
||||
141:10 warning 'AdminStatusFilterTabs' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminFormElements.tsx
|
||||
170:10 warning 'AdminCheckbox' is defined but never used @typescript-eslint/no-unused-vars
|
||||
210:10 warning 'AdminLoadingOverlay' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminPagination.tsx
|
||||
89:10 warning 'AdminSimplePagination' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminStatsBar.tsx
|
||||
23:25 warning 'i' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminTable.tsx
|
||||
83:10 warning 'TableStatusBadge' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/AdminToggle.tsx
|
||||
71:10 warning 'AdminToggleCompact' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/admin/design-system/Skeleton.tsx
|
||||
36:10 warning 'SkeletonTable' is defined but never used @typescript-eslint/no-unused-vars
|
||||
51:10 warning 'SkeletonCard' is defined but never used @typescript-eslint/no-unused-vars
|
||||
68:10 warning 'SkeletonStats' is defined but never used @typescript-eslint/no-unused-vars
|
||||
82:10 warning 'PageSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
150:10 warning 'FormSkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/landing/LandingPageWrapper.tsx
|
||||
281:11 warning 'SectionProps' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/notifications/ToastNotification.tsx
|
||||
1:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/notifications/toast-store.ts
|
||||
17:10 warning 'emit' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/AdminLookupPage.tsx
|
||||
9:10 warning 'getAgeStatus' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/LotDetailPanel.tsx
|
||||
206:10 warning 'CheckIcon' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/LotListTable.tsx
|
||||
91:3 warning 'brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/RouteTraceDashboard.tsx
|
||||
499:3 warning 'recentLots' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/RouteTracePage.tsx
|
||||
4:8 warning 'Link' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/route-trace/StickerPreviewModal.tsx
|
||||
111:14 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/CinematicShowcase.tsx
|
||||
39:10 warning '_index' is defined but never used @typescript-eslint/no-unused-vars
|
||||
40:13 warning '_isActive' is defined but never used @typescript-eslint/no-unused-vars
|
||||
56:5 warning Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars')
|
||||
113:3 warning 'brandName' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
160:13 warning 'productSwitchTrigger' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/ProductCard.tsx
|
||||
136:14 warning 'brandName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
137:3 warning 'brandAccent' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/storefront/StorefrontHeader.tsx
|
||||
55:3 warning 'isAdmin' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/time-tracking/TimeTrackingFieldClient.tsx
|
||||
20:7 warning 'BRAND_ID' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
21:7 warning 'BRAND_NAME' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
509:3 warning 'brandAccent' is defined but never used @typescript-eslint/no-unused-vars
|
||||
678:9 warning 'dailyRemaining' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/ui/ScrollAnimations.tsx
|
||||
2:1 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
|
||||
140:10 warning '_speed' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
192:10 warning 'ProgressIndicator' is defined but never used @typescript-eslint/no-unused-vars
|
||||
258:3 warning 'to' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/water/WaterAdminClient.tsx
|
||||
261:10 warning 'SummarySkeleton' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/wholesale/DepositModal.tsx
|
||||
46:9 warning 'handleBackdropClick' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/components/wholesale/OrderDetailsModal.tsx
|
||||
58:9 warning 'handleBackdropClick' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/ai-provider-models.ts
|
||||
40:10 warning 'getModelsForProvider' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/analytics.ts
|
||||
5:7 warning 'posthogHost' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
12:27 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
18:34 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
24:53 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
30:55 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
36:34 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
54:41 warning '_reason' is defined but never used @typescript-eslint/no-unused-vars
|
||||
84:51 warning '_resourceId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
102:47 warning '_context' is defined but never used @typescript-eslint/no-unused-vars
|
||||
108:57 warning '_category' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:10 warning 'identifyUser' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:23 warning '_userId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:40 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:10 warning 'groupByBrand' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:23 warning '_brandId' is defined but never used @typescript-eslint/no-unused-vars
|
||||
127:41 warning '_properties' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/date-utils.ts
|
||||
17:10 warning 'formatDateTime' is defined but never used @typescript-eslint/no-unused-vars
|
||||
29:10 warning 'formatDateISO' is defined but never used @typescript-eslint/no-unused-vars
|
||||
39:10 warning 'formatDateTimeLocal' is defined but never used @typescript-eslint/no-unused-vars
|
||||
49:10 warning 'timeAgo' is defined but never used @typescript-eslint/no-unused-vars
|
||||
69:10 warning 'getMonthName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
76:10 warning 'getMonthNameShort' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/feature-flags.ts
|
||||
117:7 warning 'CORE_FEATURES' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
200:10 warning 'invalidateBrandFeatureCache' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/format-date.ts
|
||||
31:10 warning 'formatTime' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/offline/sync.ts
|
||||
2:10 warning 'enqueueAction' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/pricing.ts
|
||||
134:7 warning 'TIER_FEATURE_MATRIX' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
171:10 warning 'formatPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
176:10 warning 'formatPricePerMonth' is defined but never used @typescript-eslint/no-unused-vars
|
||||
181:10 warning 'getPlanMonthlyPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
185:10 warning 'getPlanAnnualPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
189:10 warning 'getAddonMonthlyPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
193:10 warning 'getAddonAnnualPrice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
197:10 warning 'calculateAnnualSavings' is defined but never used @typescript-eslint/no-unused-vars
|
||||
201:7 warning 'BILLING_COMPANY_NAME' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
202:7 warning 'BILLING_EMAIL' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/pwa.ts
|
||||
34:16 warning 'subscribeToPush' is defined but never used @typescript-eslint/no-unused-vars
|
||||
34:32 warning '_registration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
40:10 warning 'isPWAInstalled' is defined but never used @typescript-eslint/no-unused-vars
|
||||
49:16 warning 'shareContent' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/rate-limit.ts
|
||||
5:6 warning 'Duration' is defined but never used @typescript-eslint/no-unused-vars
|
||||
39:9 warning 'windowStart' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
79:10 warning 'rateLimitHeaders' is defined but never used @typescript-eslint/no-unused-vars
|
||||
89:7 warning 'checkoutLimiter' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
91:7 warning 'authLimiter' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/sentry.ts
|
||||
56:7 warning 'addBreadcrumb' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
65:7 warning 'setUserContext' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
73:16 warning 'withTransaction' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/server-log.ts
|
||||
20:10 warning 'serverInfo' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/storage.ts
|
||||
84:16 warning 'deleteObject' is defined but never used @typescript-eslint/no-unused-vars
|
||||
101:16 warning 'getPresignedPutUrl' is defined but never used @typescript-eslint/no-unused-vars
|
||||
121:16 warning 'getPresignedGetUrl' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/stripe-billing.ts
|
||||
36:7 warning 'PLANS' is assigned a value but only used as a type @typescript-eslint/no-unused-vars
|
||||
171:16 warning 'createSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
215:16 warning 'createAddonSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
259:16 warning 'createCustomerPortalSession' is defined but never used @typescript-eslint/no-unused-vars
|
||||
277:16 warning 'cancelSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
287:16 warning 'reactivateSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
293:16 warning 'changePlan' is defined but never used @typescript-eslint/no-unused-vars
|
||||
340:16 warning 'getCustomer' is defined but never used @typescript-eslint/no-unused-vars
|
||||
344:16 warning 'updateCustomer' is defined but never used @typescript-eslint/no-unused-vars
|
||||
352:16 warning 'listPaymentMethods' is defined but never used @typescript-eslint/no-unused-vars
|
||||
359:16 warning 'attachPaymentMethod' is defined but never used @typescript-eslint/no-unused-vars
|
||||
365:16 warning 'setDefaultPaymentMethod' is defined but never used @typescript-eslint/no-unused-vars
|
||||
377:16 warning 'listInvoices' is defined but never used @typescript-eslint/no-unused-vars
|
||||
384:16 warning 'getInvoice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
388:16 warning 'getUpcomingInvoice' is defined but never used @typescript-eslint/no-unused-vars
|
||||
399:16 warning 'createPaymentIntent' is defined but never used @typescript-eslint/no-unused-vars
|
||||
416:16 warning 'createRefund' is defined but never used @typescript-eslint/no-unused-vars
|
||||
616:16 warning 'createUsageRecord' is defined but never used @typescript-eslint/no-unused-vars
|
||||
643:16 warning 'getUsageSummary' is defined but never used @typescript-eslint/no-unused-vars
|
||||
656:16 warning 'getSubscription' is defined but never used @typescript-eslint/no-unused-vars
|
||||
660:16 warning 'listSubscriptions' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/supabase.ts
|
||||
63:33 warning '_opts' is defined but never used @typescript-eslint/no-unused-vars
|
||||
150:5 warning '_onrejected' is defined but never used @typescript-eslint/no-unused-vars
|
||||
185:13 warning 'likeNeedle' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
188:13 warning 'ilikeNeedle' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
307:6 warning '_column' is defined but never used @typescript-eslint/no-unused-vars
|
||||
307:23 warning '_value' is defined but never used @typescript-eslint/no-unused-vars
|
||||
310:6 warning '_column' is defined but never used @typescript-eslint/no-unused-vars
|
||||
310:23 warning '_values' is defined but never used @typescript-eslint/no-unused-vars
|
||||
319:8 warning '_bucket' is defined but never used @typescript-eslint/no-unused-vars
|
||||
321:37 warning '_file' is defined but never used @typescript-eslint/no-unused-vars
|
||||
322:24 warning '_path' is defined but never used @typescript-eslint/no-unused-vars
|
||||
379:34 warning '_creds' is defined but never used @typescript-eslint/no-unused-vars
|
||||
384:26 warning '_attrs' is defined but never used @typescript-eslint/no-unused-vars
|
||||
392:17 warning '_name' is defined but never used @typescript-eslint/no-unused-vars
|
||||
392:32 warning '_params' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
/home/tyler/dev/routecomm/src/lib/water-log-reporting.ts
|
||||
38:7 warning 'WATER_LOG_DISPLAY_REFRESH_MS' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
✖ 390 problems (3 errors, 387 warnings)
|
||||
0 errors and 10 warnings potentially fixable with the `--fix` option.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
React Doctor v0.5.8
|
||||
|
||||
✔ Select projects › route-commerce-platform
|
||||
|
||||
✔ Scanned 620 files in 19.4s [~20 workers]
|
||||
No issues found!
|
||||
|
||||
┌─────┐ 100 / 100 Great
|
||||
│ ◠ ◠ │ [38;2;201;113;61m█[39m[38;2;199;115;54m█[39m[38;2;196;118;47m█[39m[38;2;194;120;40m█[39m[38;2;190;122;33m█[39m[38;2;187;125;26m█[39m[38;2;183;127;19m█[39m[38;2;179;130;13m█[39m[38;2;175;132;8m█[39m[38;2;170;135;6m█[39m[38;2;165;137;9m█[39m[38;2;159;140;14m█[39m[38;2;154;142;21m█[39m[38;2;148;145;28m█[39m[38;2;141;147;36m█[39m[38;2;134;149;43m█[39m[38;2;127;151;50m█[39m[38;2;120;153;58m█[39m[38;2;112;155;65m█[39m[38;2;104;157;72m█[39m[38;2;95;158;79m█[39m[38;2;86;159;87m█[39m[38;2;76;161;94m█[39m[38;2;65;162;101m█[39m[38;2;53;163;108m█[39m[38;2;38;163;115m█[39m[38;2;15;164;122m█[39m[38;2;0;164;129m█[39m[38;2;0;164;136m█[39m[38;2;0;164;142m█[39m[38;2;0;164;149m█[39m[38;2;0;164;155m█[39m[38;2;0;163;162m█[39m[38;2;0;162;168m█[39m[38;2;0;161;173m█[39m[38;2;0;160;179m█[39m[38;2;0;159;184m█[39m[38;2;0;158;189m█[39m[38;2;0;156;194m█[39m[38;2;0;155;198m█[39m[38;2;0;153;202m█[39m[38;2;22;151;205m█[39m[38;2;41;149;208m█[39m[38;2;54;147;211m█[39m[38;2;66;145;213m█[39m[38;2;76;143;215m█[39m[38;2;85;141;216m█[39m[38;2;94;138;217m█[39m[38;2;102;136;218m█[39m[38;2;109;134;218m█[39m
|
||||
│ ▽ │ React Doctor (https://react.doctor)
|
||||
└─────┘
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
React Doctor v0.5.8
|
||||
|
||||
✔ Select projects › route-commerce-platform
|
||||
|
||||
✔ Scanned 620 files in 22.3s [~20 workers]
|
||||
|
||||
⚠ Bugs: Plain anchor reloads internal Next.js links
|
||||
Learn more: https://react.doctor/docs/rules/react-doctor/nextjs-no-a-element
|
||||
Plain <a> reloads the whole page for internal links, so
|
||||
Next.js loses client-side navigation and prefetching.
|
||||
→ `import Link from 'next/link'` for client-side
|
||||
navigation, prefetching, and preserved scroll position
|
||||
|
||||
src/app/pricing/PricingClientPage.tsx:422
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
All 1 issue
|
||||
|
||||
Bugs › 1 warning
|
||||
|
||||
┌─────┐ 92 / 100 Great
|
||||
│ ◠ ◠ │ ██████████████████████████████████████████████░░░░
|
||||
│ ▽ │ React Doctor (https://react.doctor)
|
||||
└─────┘
|
||||
|
||||
Full diagnostics written to /tmp/react-doctor-7eb39635-105b-4770-a1df-00bffa4abaa5
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
Share: https://react.doctor/share?p=route-commerce-platform&s=92&w=1&f=1
|
||||
Tell others how you did on socials
|
||||
|
||||
Docs: https://react.doctor/docs
|
||||
Learn more about fixing issues, setting up CI/CD, and
|
||||
configuring rules with a config file
|
||||
|
||||
GitHub: https://github.com/millionco/react-doctor
|
||||
Report issues and star the repository!
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
|
||||
RUN v4.1.9 /home/tyler/dev/routecomm
|
||||
|
||||
✓ src/lib/__tests__/design-tokens.test.ts (35 tests) 9ms
|
||||
❯ tests/unit/reset-admin-password.test.ts (11 tests | 11 failed) 11ms
|
||||
× rejects unauthenticated callers 5ms
|
||||
× rejects non-platform-admin callers 1ms
|
||||
× rejects an empty email 0ms
|
||||
× looks up the user in neon_auth.user (not the legacy `users` table) 0ms
|
||||
× returns a clear error when no Neon Auth user matches the email 0ms
|
||||
× returns a server-generated temp password and flips must_change_password 0ms
|
||||
× falls back to the public reset-email endpoint when setUserPassword returns FORBIDDEN 0ms
|
||||
× falls back when setUserPassword returns UNAUTHORIZED 0ms
|
||||
× falls back when setUserPassword throws 0ms
|
||||
× does NOT fall back for USER_NOT_FOUND — it surfaces the error 1ms
|
||||
× propagates the public-endpoint error if BOTH paths fail 0ms
|
||||
❯ tests/unit/create-admin-user.test.ts (10 tests | 10 failed) 12ms
|
||||
× rejects unauthenticated callers 5ms
|
||||
× rejects non-platform-admin callers 1ms
|
||||
× uses the privileged admin endpoint when it succeeds 1ms
|
||||
× falls back to /sign-up/email when the admin endpoint fails with FORBIDDEN 1ms
|
||||
× rejects with a clear error when the email is already in use 0ms
|
||||
× surfaces the fallback error if the public sign-up fails 0ms
|
||||
× returns an error explaining the orphaned Neon Auth user 0ms
|
||||
× still returns success when the welcome email fails 0ms
|
||||
× records the email error message when sendWelcomeEmail throws 0ms
|
||||
× creates a platform admin without inserting an admin_user_brands link 1ms
|
||||
✓ tests/unit/water-log-reporting.test.ts (31 tests) 21ms
|
||||
❯ tests/unit/send-password-reset-email.test.ts (8 tests | 8 failed) 11ms
|
||||
× rejects unauthenticated callers 5ms
|
||||
× rejects non-platform-admin callers 1ms
|
||||
× rejects an empty email without calling Neon Auth 1ms
|
||||
× trims and lowercases the email before calling Neon Auth 1ms
|
||||
× returns success when Neon Auth accepts the request 1ms
|
||||
× returns a clear error when Neon Auth returns a structured error 0ms
|
||||
× falls back to the error code if the message is missing 0ms
|
||||
× catches thrown exceptions and surfaces them as a string 0ms
|
||||
✓ tests/unit/email-service.test.ts (5 tests) 23ms
|
||||
✓ src/lib/__tests__/format-date.test.ts (6 tests) 26ms
|
||||
stderr | tests/unit/sign-in-with-google.test.ts > signInWithGoogleAction > returns a structured error when Neon Auth rejects the request
|
||||
[auth/google] signIn.social error: {
|
||||
code: 'PROVIDER_NOT_CONFIGURED',
|
||||
message: 'Google OAuth is not enabled'
|
||||
}
|
||||
|
||||
stderr | tests/unit/sign-in-with-google.test.ts > signInWithGoogleAction > returns a structured error on unexpected exceptions
|
||||
[auth/google] Unexpected error: Error: network down
|
||||
at /home/tyler/dev/routecomm/tests/unit/sign-in-with-google.test.ts:95:34
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20
|
||||
at new Promise (<anonymous>)
|
||||
at runWithCancel (file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2323:10)
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20
|
||||
at new Promise (<anonymous>)
|
||||
at runWithTimeout (file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10)
|
||||
at file:///home/tyler/dev/routecomm/node_modules/.pnpm/@vitest+runner@4.1.9/node_modules/@vitest/runner/dist/chunk-artifact.js:2955:64
|
||||
|
||||
stdout | tests/unit/auth-actions.test.ts > signOutAction > calls signOut and redirects to login
|
||||
[auth/sign-out] Signing out
|
||||
|
||||
✓ tests/unit/sign-in-with-google.test.ts (6 tests) 8ms
|
||||
✓ tests/unit/auth-actions.test.ts (1 test) 3ms
|
||||
✓ src/lib/offline/queue.test.ts (8 tests) 18ms
|
||||
✓ src/lib/offline/sync.test.ts (13 tests) 15ms
|
||||
stderr | tests/unit/getAdminUser.test.ts > getAdminUser() > returns null when the user exists but has no admin_user_brands row
|
||||
[admin-permissions] Database query failed: TypeError: membershipRows.map is not a function
|
||||
at /home/tyler/dev/routecomm/src/lib/admin-permissions.ts:99:34
|
||||
at processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
|
||||
✓ tests/unit/getAdminUser.test.ts (11 tests) 8ms
|
||||
✓ tests/unit/water-log-pin.test.ts (21 tests) 365ms
|
||||
✓ tests/unit/passwords.test.ts (9 tests) 485ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯ Failed Tests 29 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — authorization > rejects unauthenticated callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:135:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — authorization > rejects non-platform-admin callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:144:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — happy path via auth.admin.createUser > uses the privileged admin endpoint when it succeeds
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:194:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — fallback to public sign-up > falls back to /sign-up/email when the admin endpoint fails with FORBIDDEN
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:253:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — fallback to public sign-up > rejects with a clear error when the email is already in use
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:276:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — fallback to public sign-up > surfaces the fallback error if the public sign-up fails
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:295:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — DB failure after auth success > returns an error explaining the orphaned Neon Auth user
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:310:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — email is best-effort > still returns success when the welcome email fails
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:355:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — email is best-effort > records the email error message when sendWelcomeEmail throws
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:400:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/29]⎯
|
||||
|
||||
FAIL tests/unit/create-admin-user.test.ts > createAdminUser — no brand > creates a platform admin without inserting an admin_user_brands link
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.createAdminUser src/actions/admin/users.ts:244:7
|
||||
242| ): Promise<CreateAdminUserResult> {
|
||||
243|
|
||||
244| await getSession(); // 1. Authorization: only platform admins can min…
|
||||
| ^
|
||||
245| const caller = await getAdminUser();
|
||||
246| if (!caller) {
|
||||
❯ tests/unit/create-admin-user.test.ts:443:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — authorization > rejects unauthenticated callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:106:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — authorization > rejects non-platform-admin callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:116:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — input handling > rejects an empty email
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:127:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — input handling > looks up the user in neon_auth.user (not the legacy `users` table)
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:134:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — input handling > returns a clear error when no Neon Auth user matches the email
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:149:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — happy path via setUserPassword > returns a server-generated temp password and flips must_change_password
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:159:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > falls back to the public reset-email endpoint when setUserPassword returns FORBIDDEN
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:188:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > falls back when setUserPassword returns UNAUTHORIZED
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:207:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > falls back when setUserPassword throws
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:215:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > does NOT fall back for USER_NOT_FOUND — it surfaces the error
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:226:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[20/29]⎯
|
||||
|
||||
FAIL tests/unit/reset-admin-password.test.ts > resetAdminPassword — FORBIDDEN fallback to requestPasswordReset > propagates the public-endpoint error if BOTH paths fail
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.resetAdminPassword src/actions/admin/reset-admin.ts:40:7
|
||||
38| ): Promise<ResetAdminPasswordResult> {
|
||||
39|
|
||||
40| await getSession(); // 1. Authz check.
|
||||
| ^
|
||||
41| const me = await getAdminUser();
|
||||
42| if (!me) {
|
||||
❯ tests/unit/reset-admin-password.test.ts:242:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[21/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — authorization > rejects unauthenticated callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:83:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[22/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — authorization > rejects non-platform-admin callers
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:91:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[23/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — input handling > rejects an empty email without calling Neon Auth
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:100:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[24/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — input handling > trims and lowercases the email before calling Neon Auth
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:107:11
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[25/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — happy path > returns success when Neon Auth accepts the request
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:118:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[26/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — error propagation > returns a clear error when Neon Auth returns a structured error
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:130:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[27/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — error propagation > falls back to the error code if the message is missing
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:140:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[28/29]⎯
|
||||
|
||||
FAIL tests/unit/send-password-reset-email.test.ts > sendPasswordResetEmail — error propagation > catches thrown exceptions and surfaces them as a string
|
||||
Error: [vitest] No "getSession" export is defined on the "@/lib/auth" mock. Did you forget to return it from "vi.mock"?
|
||||
If you need to partially mock a module, you can use "importOriginal" helper inside:
|
||||
|
||||
vi.mock(import("@/lib/auth"), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
// your mocked methods
|
||||
}
|
||||
})
|
||||
|
||||
❯ Module.sendPasswordResetEmail src/actions/admin/users.ts:592:7
|
||||
590| ): Promise<{ success: boolean; error: string | null }> {
|
||||
591|
|
||||
592| await getSession(); try {
|
||||
| ^
|
||||
593| // Authz: must be signed in as a platform_admin.
|
||||
594| const me = await getAdminUser();
|
||||
❯ tests/unit/send-password-reset-email.test.ts:147:21
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[29/29]⎯
|
||||
|
||||
|
||||
Test Files 3 failed | 11 passed (14)
|
||||
Tests 29 failed | 146 passed (175)
|
||||
Start at 17:16:50
|
||||
Duration 755ms (transform 659ms, setup 0ms, import 1.45s, tests 1.01s, environment 495ms)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# pnpm workspace configuration.
|
||||
#
|
||||
# Hardening flags recommended by react-doctor's `require-pnpm-hardening`
|
||||
# rule. `minimumReleaseAge` delays installs until new releases have had
|
||||
# time to be vetted (mitigates "catch-and-unpublish" supply-chain
|
||||
# attacks). `trustPolicy: no-downgrade` prevents pnpm from silently
|
||||
# accepting packages whose trust signals (provenance / signatures)
|
||||
# weaken between updates.
|
||||
#
|
||||
# See: https://react.doctor/docs/rules/react-doctor/require-pnpm-hardening
|
||||
|
||||
minimumReleaseAge: 10080 # 7 days, in minutes
|
||||
|
||||
trustPolicy: no-downgrade
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "react-doctor/api";
|
||||
|
||||
/**
|
||||
* React Doctor configuration for the route-commerce-platform monorepo.
|
||||
*
|
||||
* This file intentionally uses `react-doctor/api` so that the dependency is
|
||||
* imported into the source tree (not just invoked via the CLI). Without this
|
||||
* import, `deslop/unused-dev-dependency` correctly flags `react-doctor` in
|
||||
* `package.json` as unused.
|
||||
*
|
||||
* Rules deliberately left at their defaults — nothing is ignored, excluded,
|
||||
* or relaxed. The goal is a clean 100/100 score by fixing root causes, not
|
||||
* by suppressing findings.
|
||||
*/
|
||||
export default defineConfig({
|
||||
projects: ["."],
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,8 @@ import CommandPalette from "@/components/admin/CommandPalette";
|
||||
import PWAInstallPrompt from "@/components/pwa/InstallPrompt";
|
||||
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
|
||||
|
||||
// Admin layout calls getAdminUser() which reads cookies(). Without this,
|
||||
// Next.js tries to prerender the entire /admin/* tree statically and the
|
||||
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Toast provider wrapper component
|
||||
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
@@ -33,7 +29,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
let adminUser = null;
|
||||
let authError: string | null = null;
|
||||
|
||||
// Robust auth with try-catch to prevent crashes
|
||||
try {
|
||||
adminUser = await getAdminUser();
|
||||
} catch (error) {
|
||||
@@ -41,7 +36,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
authError = "Failed to verify authentication. Please try again.";
|
||||
}
|
||||
|
||||
// Auth verification failed
|
||||
if (authError) {
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
@@ -53,19 +47,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated / not provisioned
|
||||
if (!adminUser) {
|
||||
// Best-effort: surface the Neon Auth identity so the user (or support) knows
|
||||
// which account was checked. getAdminUser already logged details.
|
||||
let attemptedEmail: string | null = null;
|
||||
try {
|
||||
const { data: session } = await getSession();
|
||||
attemptedEmail = session?.user?.email ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {}
|
||||
const message = attemptedEmail
|
||||
? `Your account (${attemptedEmail}) does not have admin access. Contact a platform administrator to be provisioned.`
|
||||
? `Your account (${attemptedEmail}) does not have admin access.`
|
||||
: "Your account does not have admin access.";
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
@@ -77,14 +66,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
// Must change password
|
||||
if (adminUser.must_change_password) {
|
||||
redirect("/change-password");
|
||||
}
|
||||
|
||||
// Resolve the active brand (URL > cookie > legacy > first of brand_ids).
|
||||
// Wrapped in try/catch so a transient brand-resolution failure can't
|
||||
// crash the whole admin shell — we fall back to null (no active brand).
|
||||
let activeBrandId: string | null = null;
|
||||
try {
|
||||
activeBrandId = await getActiveBrandId(adminUser);
|
||||
@@ -92,9 +77,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
console.error("[admin/layout] getActiveBrandId failed:", err);
|
||||
}
|
||||
|
||||
// Fetch accessible brands for the sidebar BrandSelector. Wrapped in
|
||||
// try/catch so the sidebar renders empty rather than crashing the page
|
||||
// if the brands query fails.
|
||||
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
|
||||
try {
|
||||
brands = await listBrandsForAdmin();
|
||||
@@ -102,8 +84,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
console.error("[admin/layout] listBrandsForAdmin failed:", err);
|
||||
}
|
||||
|
||||
// Fetch enabled add-ons for the active brand. Used to gate Water Log /
|
||||
// Route Trace visibility in the sidebar. Empty object = all show.
|
||||
let enabledAddons: Record<string, boolean> = {};
|
||||
if (activeBrandId) {
|
||||
try {
|
||||
@@ -122,12 +102,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
brands={brands}
|
||||
enabledAddons={enabledAddons}
|
||||
/>
|
||||
{/* Cmd+K command palette — mounted globally so any admin page can summon it */}
|
||||
<CommandPalette />
|
||||
{/* The main content area swaps on every navigation. Wrapping
|
||||
it in <SmoothViewTransition> opts the swap into a soft
|
||||
crossfade via the View Transitions API — sidebar and
|
||||
background stay mounted, only the body fades. */}
|
||||
<div
|
||||
id="page-content"
|
||||
className="min-h-screen lg:pl-60 admin-section outline-none"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import Link from "next/link";
|
||||
import { AdminUserRow } from "@/actions/admin/users";
|
||||
|
||||
@@ -8,16 +8,65 @@ type ProfilePageProps = {
|
||||
currentUser: AdminUserRow;
|
||||
};
|
||||
|
||||
type State = {
|
||||
editing: boolean;
|
||||
displayName: string;
|
||||
phoneNumber: string;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
emailChangeSent: boolean;
|
||||
changingEmail: boolean;
|
||||
newEmail: string;
|
||||
emailError: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "START_EDIT" }
|
||||
| { type: "CANCEL_EDIT" }
|
||||
| { type: "SET_DISPLAY_NAME"; value: string }
|
||||
| { type: "SET_PHONE_NUMBER"; value: string }
|
||||
| { type: "SAVE_FAIL"; error: string }
|
||||
| { type: "SENT_EMAIL_CHANGE" }
|
||||
| { type: "SET_NEW_EMAIL"; value: string }
|
||||
| { type: "EMAIL_CHANGE_FAIL"; error: string };
|
||||
|
||||
function buildInitialState(currentUser: AdminUserRow): State {
|
||||
return {
|
||||
editing: false,
|
||||
displayName: currentUser.display_name ?? "",
|
||||
phoneNumber: currentUser.phone_number ?? "",
|
||||
saving: false,
|
||||
error: null,
|
||||
emailChangeSent: false,
|
||||
changingEmail: false,
|
||||
newEmail: "",
|
||||
emailError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "START_EDIT":
|
||||
return { ...state, editing: true };
|
||||
case "CANCEL_EDIT":
|
||||
return { ...state, editing: false, error: null };
|
||||
case "SET_DISPLAY_NAME":
|
||||
return { ...state, displayName: action.value };
|
||||
case "SET_PHONE_NUMBER":
|
||||
return { ...state, phoneNumber: action.value };
|
||||
case "SAVE_FAIL":
|
||||
return { ...state, saving: false, error: action.error };
|
||||
case "SENT_EMAIL_CHANGE":
|
||||
return { ...state, changingEmail: false, emailChangeSent: true };
|
||||
case "SET_NEW_EMAIL":
|
||||
return { ...state, newEmail: action.value };
|
||||
case "EMAIL_CHANGE_FAIL":
|
||||
return { ...state, changingEmail: false, emailError: action.error };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [displayName, setDisplayName] = useState(currentUser.display_name ?? "");
|
||||
const [phoneNumber, setPhoneNumber] = useState(currentUser.phone_number ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailChangeSent, setEmailChangeSent] = useState(false);
|
||||
const [changingEmail, setChangingEmail] = useState(false);
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, currentUser, buildInitialState);
|
||||
|
||||
// Profile / email mutations used to call Supabase directly. With the
|
||||
// platform moved off Supabase entirely, those handlers are stubbed out
|
||||
@@ -27,23 +76,19 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError("Profile editing is temporarily unavailable. Contact a platform admin.");
|
||||
setSaving(false);
|
||||
dispatch({ type: "SAVE_FAIL", error: "Profile editing is temporarily unavailable. Contact a platform admin." });
|
||||
}
|
||||
|
||||
async function handleEmailChange(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setChangingEmail(true);
|
||||
setEmailError("Email changes are temporarily unavailable. Contact a platform admin.");
|
||||
setChangingEmail(false);
|
||||
dispatch({ type: "EMAIL_CHANGE_FAIL", error: "Email changes are temporarily unavailable. Contact a platform admin." });
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Link
|
||||
href="/admin"
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-sm transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
@@ -55,18 +100,18 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
Manage your profile information and preferences.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-xl p-4 text-sm" style={{
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
color: "rgb(239, 68, 68)"
|
||||
}}>{error}</div>
|
||||
{state.error && (
|
||||
<div className="mt-4 rounded-xl p-4 text-sm" style={{
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
color: "rgb(239, 68, 68)"
|
||||
}}>{state.error}</div>
|
||||
)}
|
||||
|
||||
{/* Profile card */}
|
||||
<div className="mt-6 rounded-2xl p-6 shadow-lg border" style={{
|
||||
backgroundColor: "white",
|
||||
<div className="mt-6 rounded-2xl p-6 shadow-lg border" style={{
|
||||
backgroundColor: "white",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
|
||||
borderColor: "var(--admin-border)"
|
||||
borderColor: "var(--admin-border)"
|
||||
}}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -77,9 +122,9 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>{currentUser.email}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize" style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
color: "var(--admin-text-muted)"
|
||||
<span className="rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize" style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
color: "var(--admin-text-muted)"
|
||||
}}>
|
||||
{currentUser.role.replace("_", " ")}
|
||||
</span>
|
||||
@@ -88,13 +133,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!editing && (
|
||||
{!state.editing && (
|
||||
<button type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
onClick={() => dispatch({ type: "START_EDIT" })}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
color: "var(--admin-text-muted)"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
color: "var(--admin-text-muted)"
|
||||
}}
|
||||
>
|
||||
Edit Profile
|
||||
@@ -103,7 +148,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
</div>
|
||||
|
||||
{/* Read-only info when not editing */}
|
||||
{!editing && (
|
||||
{!state.editing && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -119,15 +164,15 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
)}
|
||||
|
||||
{/* Edit form */}
|
||||
{editing && (
|
||||
{state.editing && (
|
||||
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="me-display-name" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label>
|
||||
<input aria-label="Your Name"
|
||||
id="me-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
value={state.displayName}
|
||||
onChange={(e) => dispatch({ type: "SET_DISPLAY_NAME", value: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
@@ -142,8 +187,8 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
<input aria-label="+1 (555) 000 0000"
|
||||
id="me-phone"
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
value={state.phoneNumber}
|
||||
onChange={(e) => dispatch({ type: "SET_PHONE_NUMBER", value: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
@@ -157,22 +202,22 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setError(null); }}
|
||||
onClick={() => dispatch({ type: "CANCEL_EDIT" })}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
color: "var(--admin-text-muted)"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
color: "var(--admin-text-muted)"
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
disabled={state.saving}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
>
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
{state.saving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -180,22 +225,22 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
</div>
|
||||
|
||||
{/* Email change section */}
|
||||
<div className="mt-6 rounded-2xl p-6 shadow-lg border" style={{
|
||||
backgroundColor: "white",
|
||||
<div className="mt-6 rounded-2xl p-6 shadow-lg border" style={{
|
||||
backgroundColor: "white",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
|
||||
borderColor: "var(--admin-border)"
|
||||
borderColor: "var(--admin-border)"
|
||||
}}>
|
||||
<h3 className="text-base font-semibold" style={{ color: "var(--admin-text-primary)" }}>Email Address</h3>
|
||||
<p className="mt-1 text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Current: <span className="font-medium" style={{ color: "var(--admin-text-primary)" }}>{currentUser.email}</span>
|
||||
</p>
|
||||
|
||||
{emailChangeSent ? (
|
||||
<div className="mt-4 rounded-lg p-4 text-sm" style={{
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
color: "var(--admin-accent)"
|
||||
{state.emailChangeSent ? (
|
||||
<div className="mt-4 rounded-lg p-4 text-sm" style={{
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
color: "var(--admin-accent)"
|
||||
}}>
|
||||
A confirmation email has been sent to <strong>{newEmail}</strong>. Click the link in the email to confirm the change.
|
||||
A confirmation email has been sent to <strong>{state.newEmail}</strong>. Click the link in the email to confirm the change.
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
|
||||
@@ -204,8 +249,8 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
<input aria-label="New@example.com"
|
||||
id="me-new-email"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
value={state.newEmail}
|
||||
onChange={(e) => dispatch({ type: "SET_NEW_EMAIL", value: e.target.value })}
|
||||
required
|
||||
aria-required="true"
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
|
||||
@@ -218,19 +263,19 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
{emailError && (
|
||||
<div className="rounded-lg p-3 text-sm" style={{
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
color: "rgb(239, 68, 68)"
|
||||
}}>{emailError}</div>
|
||||
{state.emailError && (
|
||||
<div className="rounded-lg p-3 text-sm" style={{
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
color: "rgb(239, 68, 68)"
|
||||
}}>{state.emailError}</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changingEmail || !newEmail || !newEmail.includes("@")}
|
||||
disabled={state.changingEmail || !state.newEmail || !state.newEmail.includes("@")}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
>
|
||||
{changingEmail ? "Sending…" : "Change Email"}
|
||||
{state.changingEmail ? "Sending…" : "Change Email"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -238,4 +283,4 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
+430
-350
@@ -1,5 +1,5 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders";
|
||||
import { getAdminOrderDetail } from "@/actions/orders";
|
||||
import OrderEditForm from "@/components/admin/OrderEditForm";
|
||||
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
|
||||
import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
||||
@@ -16,6 +16,8 @@ type OrderDetailPageProps = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type OrderDetail = NonNullable<Awaited<ReturnType<typeof getAdminOrderDetail>>>;
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
quantity: number;
|
||||
@@ -31,42 +33,425 @@ function formatCurrency(amount: number) {
|
||||
return currencyFormatter.format(amount);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="inline-flex items-center gap-2 text-xs font-semibold transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Orders
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type OrderHeaderProps = {
|
||||
order: OrderDetail;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function OrderHeader({ order, total }: OrderHeaderProps) {
|
||||
return (
|
||||
<div>
|
||||
<p className="ha-eyebrow mb-2">
|
||||
Order #{order.id.slice(0, 8).toUpperCase()} · {formatDate(order.created_at)}
|
||||
</p>
|
||||
<PageHeader
|
||||
title={order.customer_name}
|
||||
subtitle={`${formatCurrency(total)} · ${order.pickup_complete ? "Picked Up" : "Awaiting Pickup"}${order.stops ? ` · ${order.stops.city}, ${order.stops.state}` : ""}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminBadge tone={order.pickup_complete ? "success" : "warning"} dot>
|
||||
{order.pickup_complete ? "Picked Up" : "Pending"}
|
||||
</AdminBadge>
|
||||
{order.payment_processor && (
|
||||
<AdminBadge tone="info">{order.payment_processor}</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CustomerInfoProps = {
|
||||
order: OrderDetail;
|
||||
brandId: string | null;
|
||||
};
|
||||
|
||||
function CustomerInfo({ order, brandId }: CustomerInfoProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Customer
|
||||
</p>
|
||||
<h2
|
||||
className="mt-1 text-2xl font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_name}
|
||||
</h2>
|
||||
</div>
|
||||
<OrderPickupAction
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
currentlyPickedUp={order.pickup_complete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-4">
|
||||
{order.customer_phone && (
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Phone
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_phone}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{order.customer_email && (
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Email
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_email}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StopInfoProps = {
|
||||
order: OrderDetail;
|
||||
};
|
||||
|
||||
function StopInfo({ order }: StopInfoProps) {
|
||||
if (!order.stops) return null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Pickup Location
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{formatDate(order.stops.date)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OrderItemsProps = {
|
||||
order: OrderDetail;
|
||||
subtotal: number;
|
||||
taxAmount: number;
|
||||
discountAmount: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function OrderItems({ order, subtotal, taxAmount, discountAmount, total }: OrderItemsProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Order Items
|
||||
</h3>
|
||||
|
||||
{order.order_items && order.order_items.length > 0 ? (
|
||||
<div
|
||||
className="mt-4 divide-y"
|
||||
style={{ borderColor: "var(--admin-border-light)" }}
|
||||
>
|
||||
{order.order_items.map((item: OrderItem) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Qty: {item.quantity} × {formatCurrency(Number(item.price))}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{formatCurrency(Number(item.price) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="mt-4 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
No items found
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Totals */}
|
||||
<div
|
||||
className="mt-6 border-t pt-6 space-y-2"
|
||||
style={{ borderColor: "var(--admin-border-light)" }}
|
||||
>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Subtotal</span>
|
||||
<span style={{ color: "var(--admin-text-primary)" }}>
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{taxAmount > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Tax</span>
|
||||
<span style={{ color: "var(--admin-text-primary)" }}>
|
||||
{formatCurrency(taxAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{discountAmount > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Discount</span>
|
||||
<span style={{ color: "var(--admin-danger)" }}>
|
||||
-{formatCurrency(discountAmount)}
|
||||
</span>
|
||||
{order.discount_reason && (
|
||||
<span
|
||||
className="text-xs ml-2"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
({order.discount_reason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex justify-between text-lg font-bold pt-2 border-t"
|
||||
style={{
|
||||
color: "var(--admin-text-primary)",
|
||||
borderColor: "var(--admin-border)",
|
||||
}}
|
||||
>
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PaymentSectionProps = {
|
||||
order: OrderDetail;
|
||||
brandId: string | null;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function PaymentSection({ order, brandId, total }: PaymentSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Payment & Refunds
|
||||
</h3>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Record payment details and manage refunds
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<OrderPaymentSection
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
orderTotal={total}
|
||||
payment_processor={order.payment_processor}
|
||||
payment_status={order.payment_status}
|
||||
payment_transaction_id={order.payment_transaction_id}
|
||||
refunded_amount={order.refunded_amount ?? 0}
|
||||
refund_reason={order.refund_reason}
|
||||
existingRefunds={order.refunds ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EditOrderSectionProps = {
|
||||
order: OrderDetail;
|
||||
brandId: string | null;
|
||||
};
|
||||
|
||||
function EditOrderSection({ order, brandId }: EditOrderSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Edit Order
|
||||
</h3>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Update customer details, pricing, and status
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type InternalNotesProps = {
|
||||
order: OrderDetail;
|
||||
};
|
||||
|
||||
function InternalNotes({ order }: InternalNotesProps) {
|
||||
if (!order.internal_notes) return null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-6"
|
||||
style={{
|
||||
borderColor: "var(--admin-warning-soft)",
|
||||
backgroundColor: "var(--admin-warning-soft)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-warning)" }}
|
||||
>
|
||||
Internal Notes
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.internal_notes}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderNotFound() {
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen px-6 py-10"
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="inline-flex items-center gap-2 text-sm transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
← Back to Orders
|
||||
</Link>
|
||||
<div
|
||||
className="mt-8 rounded-2xl border p-8 text-center"
|
||||
style={{
|
||||
borderColor: "var(--admin-danger-soft)",
|
||||
backgroundColor: "var(--admin-danger-soft)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--admin-danger)" }}
|
||||
>
|
||||
Order not found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const order = await getAdminOrderDetail(id);
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen px-6 py-10"
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="inline-flex items-center gap-2 text-sm transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
← Back to Orders
|
||||
</Link>
|
||||
<div
|
||||
className="mt-8 rounded-2xl border p-8 text-center"
|
||||
style={{
|
||||
borderColor: "var(--admin-danger-soft)",
|
||||
backgroundColor: "var(--admin-danger-soft)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--admin-danger)" }}
|
||||
>
|
||||
Order not found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <OrderNotFound />;
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -91,325 +476,20 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="inline-flex items-center gap-2 text-xs font-semibold transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Orders
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<p className="ha-eyebrow mb-2">
|
||||
Order #{order.id.slice(0, 8).toUpperCase()} · {formatDate(order.created_at)}
|
||||
</p>
|
||||
<PageHeader
|
||||
title={order.customer_name}
|
||||
subtitle={`${formatCurrency(total)} · ${order.pickup_complete ? "Picked Up" : "Awaiting Pickup"}${order.stops ? ` · ${order.stops.city}, ${order.stops.state}` : ""}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminBadge tone={order.pickup_complete ? "success" : "warning"} dot>
|
||||
{order.pickup_complete ? "Picked Up" : "Pending"}
|
||||
</AdminBadge>
|
||||
{order.payment_processor && (
|
||||
<AdminBadge tone="info">{order.payment_processor}</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Customer info */}
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Customer
|
||||
</p>
|
||||
<h2
|
||||
className="mt-1 text-2xl font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_name}
|
||||
</h2>
|
||||
</div>
|
||||
<OrderPickupAction
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
currentlyPickedUp={order.pickup_complete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-4">
|
||||
{order.customer_phone && (
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Phone
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_phone}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{order.customer_email && (
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Email
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_email}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop info */}
|
||||
{order.stops && (
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Pickup Location
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{formatDate(order.stops.date)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Order Items
|
||||
</h3>
|
||||
|
||||
{order.order_items && order.order_items.length > 0 ? (
|
||||
<div
|
||||
className="mt-4 divide-y"
|
||||
style={{ borderColor: "var(--admin-border-light)" }}
|
||||
>
|
||||
{order.order_items.map((item: OrderItem) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Qty: {item.quantity} × {formatCurrency(Number(item.price))}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{formatCurrency(Number(item.price) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="mt-4 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
No items found
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Totals */}
|
||||
<div
|
||||
className="mt-6 border-t pt-6 space-y-2"
|
||||
style={{ borderColor: "var(--admin-border-light)" }}
|
||||
>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Subtotal</span>
|
||||
<span style={{ color: "var(--admin-text-primary)" }}>
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{taxAmount > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Tax</span>
|
||||
<span style={{ color: "var(--admin-text-primary)" }}>
|
||||
{formatCurrency(taxAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{discount_amount > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Discount</span>
|
||||
<span style={{ color: "var(--admin-danger)" }}>
|
||||
-{formatCurrency(discount_amount)}
|
||||
</span>
|
||||
{order.discount_reason && (
|
||||
<span
|
||||
className="text-xs ml-2"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
({order.discount_reason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex justify-between text-lg font-bold pt-2 border-t"
|
||||
style={{
|
||||
color: "var(--admin-text-primary)",
|
||||
borderColor: "var(--admin-border)",
|
||||
}}
|
||||
>
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment & Refunds */}
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Payment & Refunds
|
||||
</h3>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Record payment details and manage refunds
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<OrderPaymentSection
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
orderTotal={total}
|
||||
payment_processor={order.payment_processor}
|
||||
payment_status={order.payment_status}
|
||||
payment_transaction_id={order.payment_transaction_id}
|
||||
refunded_amount={order.refunded_amount ?? 0}
|
||||
refund_reason={order.refund_reason}
|
||||
existingRefunds={order.refunds ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit form */}
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Edit Order
|
||||
</h3>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Update customer details, pricing, and status
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes */}
|
||||
{order.internal_notes && (
|
||||
<div
|
||||
className="rounded-2xl border p-6"
|
||||
style={{
|
||||
borderColor: "var(--admin-warning-soft)",
|
||||
backgroundColor: "var(--admin-warning-soft)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-warning)" }}
|
||||
>
|
||||
Internal Notes
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.internal_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<BackLink />
|
||||
<OrderHeader order={order} total={total} />
|
||||
<CustomerInfo order={order} brandId={brandId} />
|
||||
<StopInfo order={order} />
|
||||
<OrderItems
|
||||
order={order}
|
||||
subtotal={subtotal}
|
||||
taxAmount={taxAmount}
|
||||
discountAmount={discount_amount}
|
||||
total={total}
|
||||
/>
|
||||
<PaymentSection order={order} brandId={brandId} total={total} />
|
||||
<EditOrderSection order={order} brandId={brandId} />
|
||||
<InternalNotes order={order} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useReducer, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseProductCSV } from "@/lib/csv-parsers";
|
||||
import { importProductsBatch } from "@/actions/import-products";
|
||||
@@ -16,54 +16,103 @@ type PreviewRow = {
|
||||
_warnings: string[];
|
||||
};
|
||||
|
||||
export default function ProductImportPage() {
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewRow[] | null>(null);
|
||||
const [parseErrors, setParseErrors] = useState<{ row: number; error: string }[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ created: number; updated: number; errors: { product: string; error: string }[] } | null>(null);
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
type ImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
errors: { product: string; error: string }[];
|
||||
};
|
||||
|
||||
const SAMPLE_CSV = `name,description,price,type,active,image_url
|
||||
type State = {
|
||||
csvText: string;
|
||||
preview: PreviewRow[] | null;
|
||||
parseErrors: { row: number; error: string }[];
|
||||
importing: boolean;
|
||||
result: ImportResult | null;
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_CSV_TEXT"; value: string }
|
||||
| { type: "SET_BRAND_ID"; value: string }
|
||||
| { type: "PARSE_SUCCESS"; rows: PreviewRow[]; errors: { row: number; error: string }[] }
|
||||
| { type: "PARSE_FAIL"; error: string }
|
||||
| { type: "START_IMPORT" }
|
||||
| { type: "IMPORT_SUCCESS"; result: ImportResult }
|
||||
| { type: "IMPORT_FAIL"; error: string };
|
||||
|
||||
const initialState: State = {
|
||||
csvText: "",
|
||||
preview: null,
|
||||
parseErrors: [],
|
||||
importing: false,
|
||||
result: null,
|
||||
brandId: "",
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_CSV_TEXT":
|
||||
return { ...state, csvText: action.value };
|
||||
case "SET_BRAND_ID":
|
||||
return { ...state, brandId: action.value };
|
||||
case "PARSE_SUCCESS":
|
||||
return { ...state, preview: action.rows, parseErrors: action.errors };
|
||||
case "PARSE_FAIL":
|
||||
return { ...state, preview: null, parseErrors: [{ row: 0, error: action.error }] };
|
||||
case "START_IMPORT":
|
||||
return { ...state, importing: true };
|
||||
case "IMPORT_SUCCESS":
|
||||
return { ...state, importing: false, result: action.result };
|
||||
case "IMPORT_FAIL":
|
||||
return {
|
||||
...state,
|
||||
importing: false,
|
||||
result: { created: 0, updated: 0, errors: [{ product: "", error: action.error }] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLE_CSV = `name,description,price,type,active,image_url
|
||||
Dozen Sweet Corn,Fresh picked sweet corn,12.99,Pickup,TRUE,
|
||||
Corn & Butter Bundle,Dozen corn with herb butter,18.99,Pickup & Shipping,TRUE,
|
||||
Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
`;
|
||||
|
||||
export default function ProductImportPage() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
setCsvText(text);
|
||||
dispatch({ type: "SET_CSV_TEXT", value: text });
|
||||
handlePreview(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function handlePreview(text?: string) {
|
||||
const toParse = text ?? csvText;
|
||||
const toParse = text ?? state.csvText;
|
||||
if (!toParse.trim()) return;
|
||||
|
||||
const parseResult = await parseProductCSV(toParse);
|
||||
if (!parseResult.success) {
|
||||
setPreview(null);
|
||||
setParseErrors([{ row: 0, error: parseResult.error }]);
|
||||
dispatch({ type: "PARSE_FAIL", error: parseResult.error });
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview(parseResult.rows);
|
||||
setParseErrors(parseResult.errors);
|
||||
dispatch({ type: "PARSE_SUCCESS", rows: parseResult.rows, errors: parseResult.errors });
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!preview || !brandId) return;
|
||||
setImporting(true);
|
||||
if (!state.preview || !state.brandId) return;
|
||||
dispatch({ type: "START_IMPORT" });
|
||||
const importResult = await importProductsBatch(
|
||||
brandId,
|
||||
preview.map((r) => ({
|
||||
state.brandId,
|
||||
state.preview.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
price: r.price,
|
||||
@@ -72,11 +121,13 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
image_url: r.image_url,
|
||||
}))
|
||||
);
|
||||
setImporting(false);
|
||||
if (importResult.success) {
|
||||
setResult({ created: importResult.created, updated: importResult.updated, errors: importResult.errors });
|
||||
dispatch({
|
||||
type: "IMPORT_SUCCESS",
|
||||
result: { created: importResult.created, updated: importResult.updated, errors: importResult.errors },
|
||||
});
|
||||
} else {
|
||||
setResult({ created: 0, updated: 0, errors: [{ product: "", error: importResult.error }] });
|
||||
dispatch({ type: "IMPORT_FAIL", error: importResult.error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +146,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
setCsvText(SAMPLE_CSV);
|
||||
dispatch({ type: "SET_CSV_TEXT", value: SAMPLE_CSV });
|
||||
handlePreview(SAMPLE_CSV);
|
||||
}}
|
||||
className="rounded-xl border border-stone-300 px-4 py-2 text-sm text-stone-600 hover:bg-stone-200"
|
||||
@@ -110,8 +161,8 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
<input aria-label="64294306 5f42 463d A5e8 2ad6c81a96de (Tuxedo)"
|
||||
id="import-products-brand"
|
||||
type="text"
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
value={state.brandId}
|
||||
onChange={(e) => dispatch({ type: "SET_BRAND_ID", value: e.target.value })}
|
||||
placeholder="64294306-5f42-463d-a5e8-2ad6c81a96de (Tuxedo)"
|
||||
className="mt-1 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-blue-500"
|
||||
/>
|
||||
@@ -134,8 +185,8 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
<label htmlFor="import-products-text" className="sr-only">CSV text</label>
|
||||
<textarea aria-label="Name,description,price,type,active,image Url"
|
||||
id="import-products-text"
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
value={state.csvText}
|
||||
onChange={(e) => dispatch({ type: "SET_CSV_TEXT", value: e.target.value })}
|
||||
rows={6}
|
||||
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500"
|
||||
placeholder="name,description,price,type,active,image_url"
|
||||
@@ -149,11 +200,11 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
</div>
|
||||
|
||||
{/* Parse errors */}
|
||||
{parseErrors.length > 0 && (
|
||||
{state.parseErrors.length > 0 && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 p-4">
|
||||
<p className="text-sm font-semibold text-red-600">Parse Errors</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{parseErrors.map((e) => (
|
||||
{state.parseErrors.map((e) => (
|
||||
<li key={e.row} className="text-sm text-red-600">
|
||||
Row {e.row}: {e.error}
|
||||
</li>
|
||||
@@ -163,18 +214,18 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview !== null && (
|
||||
{state.preview !== null && (
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-stone-950">
|
||||
Preview ({preview.length} rows)
|
||||
Preview ({state.preview.length} rows)
|
||||
</h2>
|
||||
<button type="button"
|
||||
onClick={handleImport}
|
||||
disabled={!brandId || importing}
|
||||
disabled={!state.brandId || state.importing}
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-500"
|
||||
>
|
||||
{importing ? "Importing..." : `Import ${preview.length} Products`}
|
||||
{state.importing ? "Importing..." : `Import ${state.preview.length} Products`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -189,7 +240,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row) => (
|
||||
{state.preview.map((row) => (
|
||||
<tr key={row._rowIndex} className="border-b border-stone-200">
|
||||
<td className="py-2 px-3 font-medium text-stone-950">{row.name}</td>
|
||||
<td className="py-2 px-3 text-stone-600">${row.price.toFixed(2)}</td>
|
||||
@@ -207,26 +258,26 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result !== null && (
|
||||
{state.result !== null && (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<h2 className="text-lg font-semibold text-stone-950">Import Result</h2>
|
||||
<div className="mt-3 flex gap-6">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-emerald-600">{result.created}</p>
|
||||
<p className="text-3xl font-bold text-emerald-600">{state.result.created}</p>
|
||||
<p className="text-sm text-stone-500">Created</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-blue-600">{result.updated}</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{state.result.updated}</p>
|
||||
<p className="text-sm text-stone-500">Updated</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-red-600">{result.errors.length}</p>
|
||||
<p className="text-3xl font-bold text-red-600">{state.result.errors.length}</p>
|
||||
<p className="text-sm text-stone-500">Errors</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
{state.result.errors.length > 0 && (
|
||||
<ul className="mt-3 space-y-1">
|
||||
{result.errors.map((e, i) => (
|
||||
{state.result.errors.map((e, i) => (
|
||||
<li key={`${e.product}-${i}`} className="text-sm text-red-600">
|
||||
{e.product && `${e.product}: `}{e.error}
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useReducer, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseOrderCSV } from "@/lib/csv-parsers";
|
||||
import { importOrdersBatch } from "@/actions/import-orders";
|
||||
@@ -14,13 +14,63 @@ type PreviewRow = {
|
||||
_rowIndex: number;
|
||||
};
|
||||
|
||||
type ImportResult = {
|
||||
imported: number;
|
||||
errors: { row: number; error: string }[];
|
||||
};
|
||||
|
||||
type State = {
|
||||
csvText: string;
|
||||
preview: PreviewRow[] | null;
|
||||
parseErrors: { row: number; error: string }[];
|
||||
importing: boolean;
|
||||
result: ImportResult | null;
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_CSV_TEXT"; value: string }
|
||||
| { type: "SET_BRAND_ID"; value: string }
|
||||
| { type: "PARSE_SUCCESS"; rows: PreviewRow[]; errors: { row: number; error: string }[] }
|
||||
| { type: "PARSE_FAIL"; error: string }
|
||||
| { type: "START_IMPORT" }
|
||||
| { type: "IMPORT_SUCCESS"; result: ImportResult }
|
||||
| { type: "IMPORT_FAIL"; error: string };
|
||||
|
||||
const initialState: State = {
|
||||
csvText: "",
|
||||
preview: null,
|
||||
parseErrors: [],
|
||||
importing: false,
|
||||
result: null,
|
||||
brandId: "",
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_CSV_TEXT":
|
||||
return { ...state, csvText: action.value };
|
||||
case "SET_BRAND_ID":
|
||||
return { ...state, brandId: action.value };
|
||||
case "PARSE_SUCCESS":
|
||||
return { ...state, preview: action.rows, parseErrors: action.errors };
|
||||
case "PARSE_FAIL":
|
||||
return { ...state, preview: null, parseErrors: [{ row: 0, error: action.error }] };
|
||||
case "START_IMPORT":
|
||||
return { ...state, importing: true };
|
||||
case "IMPORT_SUCCESS":
|
||||
return { ...state, importing: false, result: action.result };
|
||||
case "IMPORT_FAIL":
|
||||
return {
|
||||
...state,
|
||||
importing: false,
|
||||
result: { imported: 0, errors: [{ row: 0, error: action.error }] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function SalesImportPage() {
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewRow[] | null>(null);
|
||||
const [parseErrors, setParseErrors] = useState<{ row: number; error: string }[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ imported: number; errors: { row: number; error: string }[] } | null>(null);
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const SAMPLE_CSV = `customer_name,customer_email,customer_phone,stop_id,product_id,quantity,fulfillment
|
||||
@@ -34,33 +84,31 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
setCsvText(text);
|
||||
dispatch({ type: "SET_CSV_TEXT", value: text });
|
||||
handlePreview(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function handlePreview(text?: string) {
|
||||
const toParse = text ?? csvText;
|
||||
const toParse = text ?? state.csvText;
|
||||
if (!toParse.trim()) return;
|
||||
|
||||
const parseResult = await parseOrderCSV(toParse);
|
||||
if (!parseResult.success) {
|
||||
setPreview(null);
|
||||
setParseErrors([{ row: 0, error: parseResult.error }]);
|
||||
dispatch({ type: "PARSE_FAIL", error: parseResult.error });
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview(parseResult.rows);
|
||||
setParseErrors(parseResult.errors);
|
||||
dispatch({ type: "PARSE_SUCCESS", rows: parseResult.rows, errors: parseResult.errors });
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!preview || !brandId) return;
|
||||
setImporting(true);
|
||||
if (!state.preview || !state.brandId) return;
|
||||
dispatch({ type: "START_IMPORT" });
|
||||
const importResult = await importOrdersBatch(
|
||||
brandId,
|
||||
preview.map((r) => ({
|
||||
state.brandId,
|
||||
state.preview.map((r) => ({
|
||||
customer_name: r.customer_name,
|
||||
customer_email: r.customer_email,
|
||||
customer_phone: r.customer_phone,
|
||||
@@ -68,11 +116,13 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
items: r.items,
|
||||
}))
|
||||
);
|
||||
setImporting(false);
|
||||
if (importResult.success) {
|
||||
setResult({ imported: importResult.imported, errors: importResult.errors });
|
||||
dispatch({
|
||||
type: "IMPORT_SUCCESS",
|
||||
result: { imported: importResult.imported, errors: importResult.errors },
|
||||
});
|
||||
} else {
|
||||
setResult({ imported: 0, errors: [{ row: 0, error: importResult.error }] });
|
||||
dispatch({ type: "IMPORT_FAIL", error: importResult.error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +144,8 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
<input aria-label="64294306 5f42 463d A5e8 2ad6c81a96de"
|
||||
id="import-orders-brand"
|
||||
type="text"
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
value={state.brandId}
|
||||
onChange={(e) => dispatch({ type: "SET_BRAND_ID", value: e.target.value })}
|
||||
placeholder="64294306-5f42-463d-a5e8-2ad6c81a96de"
|
||||
className="mt-1 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-blue-500 bg-white"
|
||||
/>
|
||||
@@ -108,8 +158,8 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
<label htmlFor="import-orders-text" className="sr-only">CSV text</label>
|
||||
<textarea aria-label="Import Orders Text"
|
||||
id="import-orders-text"
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
value={state.csvText}
|
||||
onChange={(e) => dispatch({ type: "SET_CSV_TEXT", value: e.target.value })}
|
||||
rows={6}
|
||||
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500 bg-white"
|
||||
/>
|
||||
@@ -121,27 +171,27 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
{state.parseErrors.length > 0 && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 p-4 border border-red-200">
|
||||
<p className="text-sm font-semibold text-red-600">Parse Errors</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{parseErrors.map((e) => (
|
||||
{state.parseErrors.map((e) => (
|
||||
<li key={e.row} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview !== null && (
|
||||
{state.preview !== null && (
|
||||
<div className="mb-4 card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-stone-950">Preview ({preview.length} rows)</h2>
|
||||
<h2 className="text-lg font-semibold text-stone-950">Preview ({state.preview.length} rows)</h2>
|
||||
<button type="button"
|
||||
onClick={handleImport}
|
||||
disabled={!brandId || importing}
|
||||
disabled={!state.brandId || state.importing}
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-700"
|
||||
>
|
||||
{importing ? "Importing..." : `Import ${preview.length} Orders`}
|
||||
{state.importing ? "Importing..." : `Import ${state.preview.length} Orders`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -155,7 +205,7 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row) => (
|
||||
{state.preview.map((row) => (
|
||||
<tr key={row._rowIndex} className="border-b border-stone-200">
|
||||
<td className="py-2 px-3 font-medium text-stone-950">{row.customer_name}</td>
|
||||
<td className="py-2 px-3 text-stone-600">{row.customer_email}</td>
|
||||
@@ -171,16 +221,16 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result !== null && (
|
||||
{state.result !== null && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-stone-950">Import Result</h2>
|
||||
<div className="mt-3">
|
||||
<p className="text-3xl font-bold text-emerald-600">{result.imported}</p>
|
||||
<p className="text-3xl font-bold text-emerald-600">{state.result.imported}</p>
|
||||
<p className="text-sm text-stone-500">Orders imported</p>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
{state.result.errors.length > 0 && (
|
||||
<ul className="mt-3 space-y-1">
|
||||
{result.errors.map((e, i) => (
|
||||
{state.result.errors.map((e, i) => (
|
||||
<li key={`row-${e.row}-${i}`} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -190,4 +240,4 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useReducer, useEffect } from "react";
|
||||
import AIProviderPanel from "@/components/admin/AIProviderPanel";
|
||||
import { savePaymentSettings } from "@/actions/payments";
|
||||
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
|
||||
import {
|
||||
saveResendCredentials,
|
||||
saveTwilioCredentials,
|
||||
testResendConnection,
|
||||
testTwilioConnection,
|
||||
getResendCredentials,
|
||||
getTwilioCredentials,
|
||||
} from "@/actions/integrations/credentials";
|
||||
import { AdminInput, AdminTextInput, AdminSelect, AdminButton } from "@/components/admin/design-system";
|
||||
import { AdminToggle } from "@/components/admin/design-system/AdminToggle";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
@@ -31,31 +37,8 @@ type Integration = {
|
||||
connected?: boolean;
|
||||
};
|
||||
|
||||
// Simplified integration cards for display (AI is handled separately)
|
||||
const COMMUNICATION_INTEGRATIONS: { id: string; name: string; icon: string; description: string; accentColor: string }[] = [
|
||||
{
|
||||
id: "resend",
|
||||
name: "Resend",
|
||||
icon: "📧",
|
||||
description: "Send transactional and marketing emails via Harvest Reach.",
|
||||
accentColor: "border-amber-200 bg-amber-50/50",
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Stripe",
|
||||
icon: "💳",
|
||||
description: "Process online payments for orders.",
|
||||
accentColor: "border-stone-200 bg-stone-50/50",
|
||||
},
|
||||
{
|
||||
id: "twilio",
|
||||
name: "Twilio",
|
||||
icon: "📱",
|
||||
description: "Send SMS campaigns and alerts via Harvest Reach.",
|
||||
accentColor: "border-blue-200 bg-blue-50/50",
|
||||
},
|
||||
];
|
||||
|
||||
// Integration cards configured below. AI is handled by AIProviderPanel
|
||||
// and Stripe/Resend/Twilio each render their own IntegrationCard.
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
{
|
||||
id: "openai",
|
||||
@@ -106,6 +89,52 @@ const INTEGRATIONS_WITHOUT_OPENAI: Integration[] = INTEGRATIONS.filter(
|
||||
(i) => i.id !== "openai"
|
||||
);
|
||||
|
||||
type CardState = {
|
||||
credentials: Record<string, string>;
|
||||
showSecrets: Record<string, boolean>;
|
||||
testResult: { ok: boolean; message: string } | null;
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type CardAction =
|
||||
| { type: "SET_CREDENTIAL"; key: string; value: string }
|
||||
| { type: "TOGGLE_SECRET"; key: string }
|
||||
| { type: "SET_TEST_RESULT"; value: { ok: boolean; message: string } | null }
|
||||
| { type: "SET_SAVING"; value: boolean }
|
||||
| { type: "SET_SAVED"; value: boolean }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "CLEAR_TEST_AND_ERROR" };
|
||||
|
||||
const initialCardState: CardState = {
|
||||
credentials: {},
|
||||
showSecrets: {},
|
||||
testResult: null,
|
||||
saving: false,
|
||||
saved: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function cardReducer(state: CardState, action: CardAction): CardState {
|
||||
switch (action.type) {
|
||||
case "SET_CREDENTIAL":
|
||||
return { ...state, credentials: { ...state.credentials, [action.key]: action.value } };
|
||||
case "TOGGLE_SECRET":
|
||||
return { ...state, showSecrets: { ...state.showSecrets, [action.key]: !state.showSecrets[action.key] } };
|
||||
case "SET_TEST_RESULT":
|
||||
return { ...state, testResult: action.value };
|
||||
case "SET_SAVING":
|
||||
return { ...state, saving: action.value };
|
||||
case "SET_SAVED":
|
||||
return { ...state, saved: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "CLEAR_TEST_AND_ERROR":
|
||||
return { ...state, testResult: null, error: null };
|
||||
}
|
||||
}
|
||||
|
||||
function IntegrationCard({
|
||||
integration,
|
||||
initialCredentials,
|
||||
@@ -115,141 +144,173 @@ function IntegrationCard({
|
||||
initialCredentials?: Record<string, string>;
|
||||
brandId: string;
|
||||
}) {
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>(
|
||||
initialCredentials ?? {}
|
||||
);
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(cardReducer, {
|
||||
...initialCardState,
|
||||
credentials: initialCredentials ?? {},
|
||||
});
|
||||
|
||||
// Derived from `credentials` + `initialCredentials` during render so we
|
||||
// don't pay for an extra commit and stale-frame window.
|
||||
const hasValues = Object.values(credentials).some((v) => v.trim().length > 0);
|
||||
const dirty = hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials);
|
||||
const hasValues = Object.values(state.credentials).some((v) => v.trim().length > 0);
|
||||
const dirty = hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(state.credentials);
|
||||
|
||||
async function handleTest() {
|
||||
setTestResult(null);
|
||||
setError(null);
|
||||
dispatch({ type: "CLEAR_TEST_AND_ERROR" });
|
||||
|
||||
if (integration.id === "resend") {
|
||||
const apiKey = credentials["RESEND_API_KEY"];
|
||||
const apiKey = state.credentials["RESEND_API_KEY"];
|
||||
if (!apiKey?.trim()) {
|
||||
setTestResult({ ok: false, message: "API key is required" });
|
||||
dispatch({ type: "SET_TEST_RESULT", value: { ok: false, message: "API key is required" } });
|
||||
return;
|
||||
}
|
||||
const result = await testResendConnection(apiKey);
|
||||
setTestResult(result);
|
||||
dispatch({ type: "SET_TEST_RESULT", value: result });
|
||||
} else if (integration.id === "twilio") {
|
||||
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
|
||||
const authToken = credentials["TWILIO_AUTH_TOKEN"];
|
||||
const accountSid = state.credentials["TWILIO_ACCOUNT_SID"];
|
||||
const authToken = state.credentials["TWILIO_AUTH_TOKEN"];
|
||||
if (!accountSid?.trim() || !authToken?.trim()) {
|
||||
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
|
||||
dispatch({ type: "SET_TEST_RESULT", value: { ok: false, message: "Account SID and Auth Token are required" } });
|
||||
return;
|
||||
}
|
||||
const result = await testTwilioConnection(accountSid, authToken);
|
||||
setTestResult(result);
|
||||
dispatch({ type: "SET_TEST_RESULT", value: result });
|
||||
} else {
|
||||
// Default test (placeholder)
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
|
||||
setTestResult(
|
||||
hasKey
|
||||
const hasKey = Object.values(state.credentials).some((v) => v.trim().length > 0);
|
||||
dispatch({
|
||||
type: "SET_TEST_RESULT",
|
||||
value: hasKey
|
||||
? { ok: true, message: `Successfully connected to ${integration.name}` }
|
||||
: { ok: false, message: `No API key configured for ${integration.name}` }
|
||||
);
|
||||
: { ok: false, message: `No API key configured for ${integration.name}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setTestResult(null);
|
||||
dispatch({ type: "SET_SAVING", value: true });
|
||||
dispatch({ type: "CLEAR_TEST_AND_ERROR" });
|
||||
|
||||
try {
|
||||
if (integration.id === "resend") {
|
||||
const result = await saveResendCredentials(brandId, {
|
||||
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
|
||||
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
|
||||
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
|
||||
api_key: state.credentials["RESEND_API_KEY"]?.trim() || null,
|
||||
from_email: state.credentials["RESEND_FROM_EMAIL"]?.trim() || null,
|
||||
from_name: state.credentials["RESEND_FROM_NAME"]?.trim() || null,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
|
||||
return;
|
||||
}
|
||||
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
dispatch({ type: "SET_TEST_RESULT", value: { ok: true, message: "Resend credentials saved successfully" } });
|
||||
dispatch({ type: "SET_SAVED", value: true });
|
||||
window.setTimeout(() => dispatch({ type: "SET_SAVED", value: false }), 3000);
|
||||
} else if (integration.id === "twilio") {
|
||||
const result = await saveTwilioCredentials(brandId, {
|
||||
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
|
||||
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
|
||||
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
|
||||
account_sid: state.credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
|
||||
auth_token: state.credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
|
||||
phone_number: state.credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
|
||||
return;
|
||||
}
|
||||
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
dispatch({ type: "SET_TEST_RESULT", value: { ok: true, message: "Twilio credentials saved successfully" } });
|
||||
dispatch({ type: "SET_SAVED", value: true });
|
||||
window.setTimeout(() => dispatch({ type: "SET_SAVED", value: false }), 3000);
|
||||
} else if (integration.id === "stripe") {
|
||||
const result = await savePaymentSettings({
|
||||
brandId,
|
||||
provider: "stripe",
|
||||
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
|
||||
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
|
||||
stripePublishableKey: state.credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
|
||||
stripeSecretKey: state.credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
|
||||
return;
|
||||
}
|
||||
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
dispatch({ type: "SET_TEST_RESULT", value: { ok: true, message: "Stripe credentials saved successfully" } });
|
||||
dispatch({ type: "SET_SAVED", value: true });
|
||||
window.setTimeout(() => dispatch({ type: "SET_SAVED", value: false }), 3000);
|
||||
} else {
|
||||
// Other integrations - just show success for now
|
||||
setTestResult({ ok: true, message: `${integration.name} settings saved` });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
dispatch({ type: "SET_TEST_RESULT", value: { ok: true, message: `${integration.name} settings saved` } });
|
||||
dispatch({ type: "SET_SAVED", value: true });
|
||||
window.setTimeout(() => dispatch({ type: "SET_SAVED", value: false }), 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save settings");
|
||||
dispatch({ type: "SET_ERROR", value: err instanceof Error ? err.message : "Failed to save settings" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
dispatch({ type: "SET_SAVING", value: false });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSecret(key: string) {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
function updateCredential(key: string, value: string) {
|
||||
setCredentials((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border p-6 bg-white ${integration.accentColor}`}>
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-bg)] text-lg">
|
||||
{integration.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-[var(--admin-text-primary)]">{integration.name}</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">{integration.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{testResult?.ok && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 px-3 py-1 text-xs font-medium text-[var(--admin-success)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<IntegrationHeader name={integration.name} icon={integration.icon} description={integration.description} ok={state.testResult?.ok ?? false} />
|
||||
|
||||
{/* Messages */}
|
||||
<StatusBanners error={state.error} saved={state.saved} testResult={state.testResult} />
|
||||
|
||||
<CredentialsForm
|
||||
credentials={integration.credentials}
|
||||
values={state.credentials}
|
||||
showSecrets={state.showSecrets}
|
||||
onChange={(key, value) => dispatch({ type: "SET_CREDENTIAL", key, value })}
|
||||
onToggleSecret={(key) => dispatch({ type: "TOGGLE_SECRET", key })}
|
||||
/>
|
||||
|
||||
<ActionButtons
|
||||
saving={state.saving}
|
||||
dirty={dirty}
|
||||
ok={state.testResult?.ok ?? false}
|
||||
onTest={handleTest}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationHeader({
|
||||
name,
|
||||
icon,
|
||||
description,
|
||||
ok,
|
||||
}: {
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
ok: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-bg)] text-lg">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-[var(--admin-text-primary)]">{name}</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{ok && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 px-3 py-1 text-xs font-medium text-[var(--admin-success)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusBannersProps = {
|
||||
error: string | null;
|
||||
saved: boolean;
|
||||
testResult: { ok: boolean; message: string } | null;
|
||||
};
|
||||
|
||||
function StatusBanners({ error, saved, testResult }: StatusBannersProps) {
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl p-4 text-sm border flex items-start gap-3 bg-[var(--admin-danger)]/10 border-[var(--admin-danger)]/30 text-[var(--admin-danger)]">
|
||||
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -268,8 +329,8 @@ function IntegrationCard({
|
||||
)}
|
||||
{testResult && (
|
||||
<div className={`mb-4 rounded-xl p-4 text-sm border flex items-start gap-3 ${
|
||||
testResult.ok
|
||||
? "bg-[var(--admin-success)]/10 border-[var(--admin-success)]/30 text-[var(--admin-success)]"
|
||||
testResult.ok
|
||||
? "bg-[var(--admin-success)]/10 border-[var(--admin-success)]/30 text-[var(--admin-success)]"
|
||||
: "bg-[var(--admin-danger)]/10 border-[var(--admin-danger)]/30 text-[var(--admin-danger)]"
|
||||
}`}>
|
||||
{testResult.ok ? (
|
||||
@@ -284,110 +345,161 @@ function IntegrationCard({
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Credentials form */}
|
||||
<div className="space-y-4 mb-5">
|
||||
{integration.credentials.map((field) => (
|
||||
<AdminInput
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
helpText={field.isSecret ? "Click Show/Hide to reveal" : undefined}
|
||||
>
|
||||
<div className="relative">
|
||||
<AdminTextInput
|
||||
type={field.isSecret && !showSecrets[field.key] ? "password" : "text"}
|
||||
value={credentials[field.key] ?? ""}
|
||||
onChange={(e) => updateCredential(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSecret(field.key)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{showSecrets[field.key] ? "Hide" : "Show"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</AdminInput>
|
||||
))}
|
||||
</div>
|
||||
type CredentialsFormProps = {
|
||||
credentials: CredentialField[];
|
||||
values: Record<string, string>;
|
||||
showSecrets: Record<string, boolean>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onToggleSecret: (key: string) => void;
|
||||
};
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border)]">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={saving}
|
||||
function CredentialsForm({ credentials, values, showSecrets, onChange, onToggleSecret }: CredentialsFormProps) {
|
||||
return (
|
||||
<div className="space-y-4 mb-5">
|
||||
{credentials.map((field) => (
|
||||
<AdminInput
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
helpText={field.isSecret ? "Click Show/Hide to reveal" : undefined}
|
||||
>
|
||||
{testResult?.ok ? "Re-test" : "Test Connection"}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</AdminButton>
|
||||
{dirty && !saving && (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<AdminTextInput
|
||||
type={field.isSecret && !showSecrets[field.key] ? "password" : "text"}
|
||||
value={values[field.key] ?? ""}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleSecret(field.key)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{showSecrets[field.key] ? "Hide" : "Show"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</AdminInput>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionButtonsProps = {
|
||||
saving: boolean;
|
||||
dirty: boolean;
|
||||
ok: boolean;
|
||||
onTest: () => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
function ActionButtons({ saving, dirty, ok, onTest, onSave }: ActionButtonsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border)]">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={saving}
|
||||
>
|
||||
{ok ? "Re-test" : "Test Connection"}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={saving || !dirty}
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</AdminButton>
|
||||
{dirty && !saving && (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PageState = {
|
||||
selectedBrandId: string;
|
||||
initialCredentials: Record<string, Record<string, string>>;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
type PageAction =
|
||||
| { type: "SET_SELECTED_BRAND"; value: string }
|
||||
| { type: "SET_INITIAL_CREDENTIALS"; value: Record<string, Record<string, string>> }
|
||||
| { type: "SET_LOADING"; value: boolean };
|
||||
|
||||
const initialPageState: PageState = {
|
||||
selectedBrandId: "",
|
||||
initialCredentials: {},
|
||||
loading: true,
|
||||
};
|
||||
|
||||
function pageReducer(state: PageState, action: PageAction): PageState {
|
||||
switch (action.type) {
|
||||
case "SET_SELECTED_BRAND":
|
||||
return { ...state, selectedBrandId: action.value };
|
||||
case "SET_INITIAL_CREDENTIALS":
|
||||
return { ...state, initialCredentials: action.value };
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmin }: Props) {
|
||||
// `selectedBrandId` is the platform admin's explicit brand selection. The
|
||||
// initial value is not derived from a prop so the value stays in sync with
|
||||
// the server-truth `brandId` once a user makes a choice: we always render
|
||||
// off `effectiveBrandId = selectedBrandId || brandId`, so any change to
|
||||
// `brandId` (e.g., route nav) is reflected until the user picks another.
|
||||
const [selectedBrandId, setSelectedBrandId] = useState<string>("");
|
||||
const effectiveBrandId = selectedBrandId || brandId;
|
||||
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [state, dispatch] = useReducer(pageReducer, initialPageState);
|
||||
const effectiveBrandId = state.selectedBrandId || brandId;
|
||||
|
||||
// Fetch initial credentials for each integration
|
||||
useEffect(() => {
|
||||
async function fetchCredentials() {
|
||||
setLoading(true);
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
try {
|
||||
const [resendCreds, twilioCreds] = await Promise.all([
|
||||
getResendCredentials(effectiveBrandId),
|
||||
getTwilioCredentials(effectiveBrandId),
|
||||
]);
|
||||
|
||||
setInitialCredentials({
|
||||
resend: {
|
||||
RESEND_API_KEY: resendCreds.api_key ?? "",
|
||||
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
|
||||
RESEND_FROM_NAME: resendCreds.from_name ?? "",
|
||||
},
|
||||
twilio: {
|
||||
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
|
||||
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
|
||||
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
|
||||
dispatch({
|
||||
type: "SET_INITIAL_CREDENTIALS",
|
||||
value: {
|
||||
resend: {
|
||||
RESEND_API_KEY: resendCreds.api_key ?? "",
|
||||
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
|
||||
RESEND_FROM_NAME: resendCreds.from_name ?? "",
|
||||
},
|
||||
twilio: {
|
||||
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
|
||||
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
|
||||
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
}
|
||||
}
|
||||
fetchCredentials();
|
||||
@@ -395,78 +507,119 @@ export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmi
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-4xl">
|
||||
{/* Brand selector for platform admins */}
|
||||
{isPlatformAdmin && brands.length > 0 && (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-5">
|
||||
<AdminInput label="Select Brand" helpText="Choose a brand to configure integrations for:">
|
||||
<AdminSelect
|
||||
value={effectiveBrandId}
|
||||
onChange={(e) => setSelectedBrandId(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
)}
|
||||
<BrandSelector
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
brands={brands}
|
||||
effectiveBrandId={effectiveBrandId}
|
||||
onSelectBrand={(value) => dispatch({ type: "SET_SELECTED_BRAND", value })}
|
||||
/>
|
||||
|
||||
{/* AI Provider Section */}
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-700 text-sm">🤖</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Configure AI models for intelligent features</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/admin/settings/ai" className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
|
||||
Configure AI
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<AIProviderPanel brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
<AIProviderSection brandId={effectiveBrandId} />
|
||||
|
||||
{/* Payment & Email integrations grid */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Communication & Payments</h2>
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Loading integrations...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
initialCredentials={initialCredentials[integration.id]}
|
||||
brandId={effectiveBrandId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IntegrationsList
|
||||
loading={state.loading}
|
||||
initialCredentials={state.initialCredentials}
|
||||
effectiveBrandId={effectiveBrandId}
|
||||
/>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-[var(--admin-text-muted)] shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<HelpFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BrandSelectorProps = {
|
||||
isPlatformAdmin: boolean;
|
||||
brands: { id: string; name: string }[];
|
||||
effectiveBrandId: string;
|
||||
onSelectBrand: (value: string) => void;
|
||||
};
|
||||
|
||||
function BrandSelector({ isPlatformAdmin, brands, effectiveBrandId, onSelectBrand }: BrandSelectorProps) {
|
||||
if (!isPlatformAdmin || brands.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-5">
|
||||
<AdminInput label="Select Brand" helpText="Choose a brand to configure integrations for:">
|
||||
<AdminSelect
|
||||
value={effectiveBrandId}
|
||||
onChange={(e) => onSelectBrand(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AIProviderSection({ brandId }: { brandId: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-700 text-sm">🤖</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Need help?</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
||||
Contact us at <a href="mailto:support@cielohermosa.com" className="underline hover:text-[var(--admin-accent)]">support@cielohermosa.com</a> for assistance setting up integrations.
|
||||
</p>
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Configure AI models for intelligent features</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/admin/settings/ai" className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
|
||||
Configure AI
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<AIProviderPanel brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type IntegrationsListProps = {
|
||||
loading: boolean;
|
||||
initialCredentials: Record<string, Record<string, string>>;
|
||||
effectiveBrandId: string;
|
||||
};
|
||||
|
||||
function IntegrationsList({ loading, initialCredentials, effectiveBrandId }: IntegrationsListProps) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Communication & Payments</h2>
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Loading integrations...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
initialCredentials={initialCredentials[integration.id]}
|
||||
brandId={effectiveBrandId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpFooter() {
|
||||
return (
|
||||
<div className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-[var(--admin-text-muted)] shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Need help?</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
||||
Contact us at <a href="mailto:support@cielohermosa.com" className="underline hover:text-[var(--admin-accent)]">support@cielohermosa.com</a> for assistance setting up integrations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
||||
* a focused "form" feel.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useReducer, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getWaterAdminSettings,
|
||||
saveWaterAdminSettings,
|
||||
@@ -25,50 +25,166 @@ import {
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export default function WaterLogSettingsPage() {
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [revealedPin, setRevealedPin] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
type Message = { type: "success" | "error"; text: string } | null;
|
||||
|
||||
// Form state
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [sessionDuration, setSessionDuration] = useState(12);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [canDelete, setCanDelete] = useState(true);
|
||||
const [canExport, setCanExport] = useState(true);
|
||||
const [alertPhone, setAlertPhone] = useState("");
|
||||
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
||||
type State = {
|
||||
settings: AdminSettings | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
regenerating: boolean;
|
||||
revealedPin: string | null;
|
||||
message: Message;
|
||||
enabled: boolean;
|
||||
sessionDuration: number;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canExport: boolean;
|
||||
alertPhone: string;
|
||||
alertsEnabled: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "LOAD_START" }
|
||||
| {
|
||||
type: "LOAD_SUCCESS";
|
||||
data: AdminSettings;
|
||||
}
|
||||
| { type: "LOAD_DONE" }
|
||||
| { type: "SAVE_START" }
|
||||
| { type: "SAVE_SUCCESS"; settings: AdminSettings | undefined }
|
||||
| { type: "SAVE_FAIL"; message: Message }
|
||||
| { type: "SAVE_DONE" }
|
||||
| { type: "REGEN_START" }
|
||||
| { type: "REGEN_SUCCESS"; pin: string; message: Message }
|
||||
| { type: "REGEN_FAIL"; message: Message }
|
||||
| { type: "REGEN_DONE" }
|
||||
| { type: "DISMISS_PIN" }
|
||||
| { type: "CLEAR_MESSAGE" }
|
||||
| { type: "SET_ENABLED"; value: boolean }
|
||||
| { type: "SET_SESSION_DURATION"; value: number }
|
||||
| { type: "SET_CAN_EDIT"; value: boolean }
|
||||
| { type: "SET_CAN_DELETE"; value: boolean }
|
||||
| { type: "SET_CAN_EXPORT"; value: boolean }
|
||||
| { type: "SET_ALERT_PHONE"; value: string }
|
||||
| { type: "SET_ALERTS_ENABLED"; value: boolean };
|
||||
|
||||
const initialState: State = {
|
||||
settings: null,
|
||||
loading: true,
|
||||
saving: false,
|
||||
regenerating: false,
|
||||
revealedPin: null,
|
||||
message: null,
|
||||
enabled: false,
|
||||
sessionDuration: 12,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canExport: true,
|
||||
alertPhone: "",
|
||||
alertsEnabled: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "LOAD_START":
|
||||
return { ...state, loading: true };
|
||||
case "LOAD_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
settings: action.data,
|
||||
enabled: action.data.enabled,
|
||||
sessionDuration: action.data.sessionDurationHours,
|
||||
canEdit: action.data.canEditEntries,
|
||||
canDelete: action.data.canDeleteEntries,
|
||||
canExport: action.data.canExportCsv,
|
||||
alertPhone: action.data.alertPhone ?? "",
|
||||
alertsEnabled: action.data.alertsEnabled,
|
||||
};
|
||||
case "LOAD_DONE":
|
||||
return { ...state, loading: false };
|
||||
case "SAVE_START":
|
||||
return { ...state, saving: true, message: null };
|
||||
case "SAVE_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
message: { type: "success", text: "Settings saved" },
|
||||
settings: action.settings ?? state.settings,
|
||||
};
|
||||
case "SAVE_FAIL":
|
||||
return { ...state, message: action.message };
|
||||
case "SAVE_DONE":
|
||||
return { ...state, saving: false };
|
||||
case "REGEN_START":
|
||||
return { ...state, regenerating: true, message: null };
|
||||
case "REGEN_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
revealedPin: action.pin,
|
||||
message: action.message,
|
||||
};
|
||||
case "REGEN_FAIL":
|
||||
return { ...state, message: action.message };
|
||||
case "REGEN_DONE":
|
||||
return { ...state, regenerating: false };
|
||||
case "DISMISS_PIN":
|
||||
return { ...state, revealedPin: null };
|
||||
case "CLEAR_MESSAGE":
|
||||
return { ...state, message: null };
|
||||
case "SET_ENABLED":
|
||||
return { ...state, enabled: action.value };
|
||||
case "SET_SESSION_DURATION":
|
||||
return { ...state, sessionDuration: action.value };
|
||||
case "SET_CAN_EDIT":
|
||||
return { ...state, canEdit: action.value };
|
||||
case "SET_CAN_DELETE":
|
||||
return { ...state, canDelete: action.value };
|
||||
case "SET_CAN_EXPORT":
|
||||
return { ...state, canExport: action.value };
|
||||
case "SET_ALERT_PHONE":
|
||||
return { ...state, alertPhone: action.value };
|
||||
case "SET_ALERTS_ENABLED":
|
||||
return { ...state, alertsEnabled: action.value };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function WaterLogSettingsPage() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const {
|
||||
settings,
|
||||
loading,
|
||||
saving,
|
||||
regenerating,
|
||||
revealedPin,
|
||||
message,
|
||||
enabled,
|
||||
sessionDuration,
|
||||
canEdit,
|
||||
canDelete,
|
||||
canExport,
|
||||
alertPhone,
|
||||
alertsEnabled,
|
||||
} = state;
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
dispatch({ type: "LOAD_START" });
|
||||
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
|
||||
if (data) {
|
||||
setSettings(data);
|
||||
setEnabled(data.enabled);
|
||||
setSessionDuration(data.sessionDurationHours);
|
||||
setCanEdit(data.canEditEntries);
|
||||
setCanDelete(data.canDeleteEntries);
|
||||
setCanExport(data.canExportCsv);
|
||||
setAlertPhone(data.alertPhone ?? "");
|
||||
setAlertsEnabled(data.alertsEnabled);
|
||||
dispatch({ type: "LOAD_SUCCESS", data });
|
||||
}
|
||||
setLoading(false);
|
||||
dispatch({ type: "LOAD_DONE" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Load on mount — setState-in-effect is intentional here
|
||||
// because we're hydrating from server data.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
dispatch({ type: "SAVE_START" });
|
||||
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
|
||||
enabled,
|
||||
sessionDurationHours: sessionDuration,
|
||||
@@ -79,31 +195,38 @@ export default function WaterLogSettingsPage() {
|
||||
alertsEnabled,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage({ type: "success", text: "Settings saved" });
|
||||
if (result.settings) setSettings(result.settings);
|
||||
dispatch({ type: "SAVE_SUCCESS", settings: result.settings });
|
||||
} else {
|
||||
setMessage({ type: "error", text: result.error ?? "Save failed" });
|
||||
dispatch({
|
||||
type: "SAVE_FAIL",
|
||||
message: { type: "error", text: result.error ?? "Save failed" },
|
||||
});
|
||||
}
|
||||
setSaving(false);
|
||||
dispatch({ type: "SAVE_DONE" });
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
|
||||
return;
|
||||
}
|
||||
setRegenerating(true);
|
||||
setMessage(null);
|
||||
dispatch({ type: "REGEN_START" });
|
||||
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
|
||||
if (result.success && result.pin) {
|
||||
setRevealedPin(result.pin);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "New PIN generated. Save it now — it will not be shown again.",
|
||||
dispatch({
|
||||
type: "REGEN_SUCCESS",
|
||||
pin: result.pin,
|
||||
message: {
|
||||
type: "success",
|
||||
text: "New PIN generated. Save it now — it will not be shown again.",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" });
|
||||
dispatch({
|
||||
type: "REGEN_FAIL",
|
||||
message: { type: "error", text: result.error ?? "Failed to regenerate PIN" },
|
||||
});
|
||||
}
|
||||
setRegenerating(false);
|
||||
dispatch({ type: "REGEN_DONE" });
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -153,7 +276,7 @@ export default function WaterLogSettingsPage() {
|
||||
label="Enable"
|
||||
description="Allow water admins to sign in with a 4-digit PIN."
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
onChange={(value) => dispatch({ type: "SET_ENABLED", value })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -176,7 +299,7 @@ export default function WaterLogSettingsPage() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealedPin(null)}
|
||||
onClick={() => dispatch({ type: "DISMISS_PIN" })}
|
||||
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
|
||||
>
|
||||
I've saved it — dismiss
|
||||
@@ -209,7 +332,12 @@ export default function WaterLogSettingsPage() {
|
||||
min={1}
|
||||
max={168}
|
||||
value={sessionDuration}
|
||||
onChange={(e) => setSessionDuration(parseInt(e.target.value, 10))}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: "SET_SESSION_DURATION",
|
||||
value: parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
className="flex-1 accent-[#1a4d2e]"
|
||||
aria-label="Session duration in hours"
|
||||
/>
|
||||
@@ -226,19 +354,19 @@ export default function WaterLogSettingsPage() {
|
||||
label="Edit entries"
|
||||
description="Allow modifying existing log entries."
|
||||
value={canEdit}
|
||||
onChange={setCanEdit}
|
||||
onChange={(value) => dispatch({ type: "SET_CAN_EDIT", value })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Delete entries"
|
||||
description="Allow removing log entries."
|
||||
value={canDelete}
|
||||
onChange={setCanDelete}
|
||||
onChange={(value) => dispatch({ type: "SET_CAN_DELETE", value })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Export CSV"
|
||||
description="Allow exporting water log data."
|
||||
value={canExport}
|
||||
onChange={setCanExport}
|
||||
onChange={(value) => dispatch({ type: "SET_CAN_EXPORT", value })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -249,7 +377,7 @@ export default function WaterLogSettingsPage() {
|
||||
label="Enable alerts"
|
||||
description="Threshold breach notifications."
|
||||
value={alertsEnabled}
|
||||
onChange={setAlertsEnabled}
|
||||
onChange={(value) => dispatch({ type: "SET_ALERTS_ENABLED", value })}
|
||||
/>
|
||||
{alertsEnabled && (
|
||||
<div className="mt-4">
|
||||
@@ -260,7 +388,9 @@ export default function WaterLogSettingsPage() {
|
||||
id="water-alert-phone"
|
||||
type="tel"
|
||||
value={alertPhone}
|
||||
onChange={(e) => setAlertPhone(e.target.value)}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "SET_ALERT_PHONE", value: e.target.value })
|
||||
}
|
||||
placeholder="+13035551234"
|
||||
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||
autoComplete="tel"
|
||||
@@ -342,4 +472,4 @@ function ToggleRow({
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
function isValidEmail(e: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||
}
|
||||
|
||||
export default function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function handleAdd() {
|
||||
if (!email.trim()) return;
|
||||
if (!isValidEmail(email.trim())) {
|
||||
setError("Enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onAdd(email.trim(), name.trim());
|
||||
setEmail("");
|
||||
setName("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<input aria-label="Email Address"
|
||||
type="email" placeholder="Email address"
|
||||
value={email} onChange={e => { setEmail(e.target.value); setError(""); }}
|
||||
onKeyDown={e => e.key === "Enter" && handleAdd()}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<input aria-label="Name (optional)"
|
||||
type="text" placeholder="Name (optional)"
|
||||
value={name} onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleAdd()}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Add
|
||||
</AdminButton>
|
||||
{error && <p className="text-xs text-[var(--admin-danger)] mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type WholesaleCustomer,
|
||||
} from "@/actions/wholesale";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
import { type CustomerForm } from "./customerFormState";
|
||||
|
||||
type CustomerFormSectionProps = {
|
||||
editing: WholesaleCustomer | null;
|
||||
form: CustomerForm;
|
||||
saving: boolean;
|
||||
onFieldChange: <K extends keyof CustomerForm>(field: K, value: CustomerForm[K]) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function CustomerFormSection({ editing, form, saving, onFieldChange, onSave, onCancel }: CustomerFormSectionProps) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Customer" : "New Customer"}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="ws-company-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label>
|
||||
<input aria-label="Ws Company Name" id="ws-company-name" value={form.companyName} onChange={e => onFieldChange("companyName", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ws-contact-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label>
|
||||
<input aria-label="Ws Contact Name" id="ws-contact-name" value={form.contactName} onChange={e => onFieldChange("contactName", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ws-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label>
|
||||
<input aria-label="Ws Email" id="ws-email" type="email" value={form.email} onChange={e => onFieldChange("email", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ws-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label>
|
||||
<input aria-label="Ws Phone" id="ws-phone" type="tel" value={form.phone} onChange={e => onFieldChange("phone", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="tel" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ws-account-status" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label>
|
||||
<select aria-label="Ws Account Status" id="ws-account-status" value={form.accountStatus} onChange={e => onFieldChange("accountStatus", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="active">Active</option>
|
||||
<option value="on_hold">On Hold</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="pending_approval">Pending Approval</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ws-credit-limit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label>
|
||||
<input aria-label="0 = Unlimited" id="ws-credit-limit" type="number" value={form.creditLimit} onChange={e => onFieldChange("creditLimit", Number(e.target.value))}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0 = unlimited" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 rounded-xl bg-[var(--admin-bg-subtle)] space-y-3">
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-secondary)]">Deposit Rules</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input aria-label="Dep Enabled" type="checkbox" checked={form.depositsEnabled}
|
||||
onChange={e => onFieldChange("depositsEnabled", e.target.checked)}
|
||||
className="rounded" id="dep-enabled" />
|
||||
<label htmlFor="dep-enabled" className="text-sm text-[var(--admin-text-secondary)]">Enable deposit requirement</label>
|
||||
</div>
|
||||
{form.depositsEnabled && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="ws-deposit-threshold" className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label>
|
||||
<input aria-label="0" id="ws-deposit-threshold" type="number" value={form.depositThreshold} onChange={e => onFieldChange("depositThreshold", e.target.value)}
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ws-deposit-pct" className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label>
|
||||
<input aria-label="20" id="ws-deposit-pct" type="number" value={form.depositPercentage} onChange={e => onFieldChange("depositPercentage", e.target.value)}
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-3">
|
||||
<AdminButton onClick={onSave} disabled={saving} isLoading={saving} variant="primary">
|
||||
{saving ? "Saving..." : "Save Customer"}
|
||||
</AdminButton>
|
||||
<AdminButton onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type WholesaleCustomer,
|
||||
} from "@/actions/wholesale";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
|
||||
|
||||
type CustomerListSectionProps = {
|
||||
customers: WholesaleCustomer[];
|
||||
selectedCustomers: Set<string>;
|
||||
allCustomerIds: string[];
|
||||
openCustomerActions: string | null;
|
||||
deletingCustomer: string | null;
|
||||
sendingPriceSheet: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
onToggleAll: () => void;
|
||||
onToggleActions: (id: string) => void;
|
||||
onCloseActions: () => void;
|
||||
onEdit: (c: WholesaleCustomer) => void;
|
||||
onPricing: (c: WholesaleCustomer) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSendPriceSheet: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
export default function CustomerListSection({
|
||||
customers,
|
||||
selectedCustomers,
|
||||
allCustomerIds,
|
||||
openCustomerActions,
|
||||
deletingCustomer,
|
||||
sendingPriceSheet,
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
onToggleActions,
|
||||
onCloseActions,
|
||||
onEdit,
|
||||
onPricing,
|
||||
onDelete,
|
||||
onSendPriceSheet,
|
||||
}: CustomerListSectionProps) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-semibold text-left">
|
||||
<input aria-label="Checkbox" type="checkbox" className="rounded border-[var(--admin-border)]"
|
||||
checked={selectedCustomers.size === customers.length && customers.length > 0}
|
||||
onChange={onToggleAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Company</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Contact</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Credit</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Deposits</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Created</th>
|
||||
<th className="px-5 py-3" aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{customers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-600 mb-1">No customers yet</p>
|
||||
<p className="text-xs text-slate-400">Wholesale customers will appear here once registered.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : customers.map(c => (
|
||||
<CustomerRow
|
||||
key={c.id}
|
||||
customer={c}
|
||||
isSelected={selectedCustomers.has(c.id)}
|
||||
isActionsOpen={openCustomerActions === c.id}
|
||||
isDeleting={deletingCustomer === c.id}
|
||||
onToggle={() => onToggle(c.id)}
|
||||
onToggleActions={() => onToggleActions(c.id)}
|
||||
onCloseActions={onCloseActions}
|
||||
onEdit={() => onEdit(c)}
|
||||
onPricing={() => onPricing(c)}
|
||||
onDelete={() => onDelete(c.id)}
|
||||
onSendPriceSheet={() => onSendPriceSheet([c.id])}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
{selectedCustomers.size > 0 && (
|
||||
<tfoot className="bg-[var(--admin-accent-light)] border-t border-[var(--admin-accent)]">
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-3 flex items-center gap-4">
|
||||
<span className="text-sm text-[var(--admin-accent-text)] font-medium">{selectedCustomers.size} selected</span>
|
||||
<AdminButton
|
||||
onClick={() => onSendPriceSheet(allCustomerIds.filter(id => selectedCustomers.has(id)))}
|
||||
disabled={sendingPriceSheet}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={sendingPriceSheet}
|
||||
>
|
||||
{sendingPriceSheet ? "Sending..." : `Send Price Sheet to ${selectedCustomers.size} Customer(s)`}
|
||||
</AdminButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CustomerRowProps = {
|
||||
customer: WholesaleCustomer;
|
||||
isSelected: boolean;
|
||||
isActionsOpen: boolean;
|
||||
isDeleting: boolean;
|
||||
onToggle: () => void;
|
||||
onToggleActions: () => void;
|
||||
onCloseActions: () => void;
|
||||
onEdit: () => void;
|
||||
onPricing: () => void;
|
||||
onDelete: () => void;
|
||||
onSendPriceSheet: () => void;
|
||||
};
|
||||
|
||||
function CustomerRow({
|
||||
customer: c,
|
||||
isSelected,
|
||||
isActionsOpen,
|
||||
isDeleting,
|
||||
onToggle,
|
||||
onToggleActions,
|
||||
onCloseActions,
|
||||
onEdit,
|
||||
onPricing,
|
||||
onDelete,
|
||||
onSendPriceSheet,
|
||||
}: CustomerRowProps) {
|
||||
return (
|
||||
<tr className="hover:bg-[var(--admin-bg-subtle)]">
|
||||
<td className="px-5 py-3">
|
||||
<input aria-label="Checkbox" type="checkbox" className="rounded border-[var(--admin-border)]"
|
||||
checked={isSelected}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{c.company_name ?? "—"}</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{c.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{c.email}</span></td>
|
||||
<td className="px-5 py-3">
|
||||
<AdminBadge variant={c.account_status === "active" ? "success" : c.account_status === "on_hold" ? "warning" : "danger"}>
|
||||
{c.account_status}
|
||||
</AdminBadge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">
|
||||
{c.credit_limit <= 0 ? "Unlimited" : `$${Number(c.credit_limit).toFixed(2)}`}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">
|
||||
{c.deposits_enabled ? `${c.deposit_percentage}%` : "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-muted)] text-xs">{formatDate(new Date(c.created_at))}</td>
|
||||
<td className="px-5 py-4 customer-actions-cell relative">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button type="button"
|
||||
onClick={onSendPriceSheet}
|
||||
title="Send price sheet"
|
||||
aria-label="Send price sheet"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-accent)] hover:text-[var(--admin-accent-text)] hover:bg-[var(--admin-accent-light)] transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button type="button"
|
||||
onClick={onToggleActions}
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
{isActionsOpen && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-1 z-30 w-52 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={() => { onCloseActions(); onEdit(); }}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Edit Customer
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { onCloseActions(); onPricing(); }}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Pricing
|
||||
</button>
|
||||
<a
|
||||
href={`/wholesale/portal?preview_customer_id=${c.id}&_admin_preview=1`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
View Portal As
|
||||
</a>
|
||||
<div className="my-1 border-t border-[var(--admin-border)]" />
|
||||
<button type="button"
|
||||
onClick={() => { onCloseActions(); onDelete(); }}
|
||||
disabled={isDeleting}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
{isDeleting ? "Deleting..." : "Delete Customer"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
type WholesaleCustomer,
|
||||
type WholesaleProduct,
|
||||
} from "@/actions/wholesale";
|
||||
import { getWholesaleCustomerPricing, upsertWholesaleCustomerPricing, deleteWholesaleCustomerPricing } from "@/actions/wholesale-register";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
export default function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
|
||||
customer: WholesaleCustomer;
|
||||
products: WholesaleProduct[];
|
||||
onClose: () => void;
|
||||
onMsg: (kind: "success" | "error", text: string) => void;
|
||||
}) {
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving] = useState(false);
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
getWholesaleCustomerPricing(customer.id).then(pricing => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of pricing) {
|
||||
map[p.product_id] = p.custom_unit_price.toString();
|
||||
}
|
||||
setOverrides(map);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [customer.id]);
|
||||
|
||||
async function handleSave(productId: string, price: string) {
|
||||
if (!price || isNaN(Number(price))) {
|
||||
await deleteWholesaleCustomerPricing({ customerId: customer.id, productId });
|
||||
setOverrides(prev => { const n = { ...prev }; delete n[productId]; return n; });
|
||||
} else {
|
||||
await upsertWholesaleCustomerPricing({ customerId: customer.id, productId, customUnitPrice: Number(price) });
|
||||
setOverrides(prev => ({ ...prev, [productId]: price }));
|
||||
}
|
||||
onMsg("success", "Pricing updated.");
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Pricing overrides"
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col border border-[var(--admin-border)]">
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Pricing Overrides</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">{customer.company_name}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close pricing overrides" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 px-6 py-4">
|
||||
{loading ? (
|
||||
<p className="text-[var(--admin-text-muted)] text-sm">Loading...</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className="text-[var(--admin-text-muted)] text-sm">No products available. Add wholesale products to see them here.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--admin-text-muted)] border-b">
|
||||
<th className="pb-2 font-medium">Product</th>
|
||||
<th className="pb-2 font-medium">Standard Price</th>
|
||||
<th className="pb-2 font-medium">Override Price</th>
|
||||
<th className="pb-2" aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{products.map(p => {
|
||||
const standardPrice = p.price_tiers?.[0]?.price ?? 0;
|
||||
const overridePrice = overrides[p.id] ?? "";
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td className="py-2.5 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
|
||||
<td className="py-2.5 text-[var(--admin-text-muted)]">${standardPrice.toFixed(2)}</td>
|
||||
<td className="py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">$</span>
|
||||
<input aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={overridePrice}
|
||||
onChange={e => setOverrides(prev => ({ ...prev, [p.id]: e.target.value }))}
|
||||
placeholder={standardPrice.toFixed(2)}
|
||||
className="w-24 rounded-lg border border-[var(--admin-border)] px-2 py-1 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
{overridePrice && overridePrice !== standardPrice.toFixed(2) && (
|
||||
<span className="text-xs text-[var(--admin-accent)] font-medium">Custom</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleSave(p.id, overrides[p.id] ?? "")}
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</AdminButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-[var(--admin-text-muted)]">
|
||||
Leave override price blank to remove custom pricing and use standard price tiers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useEffect } from "react";
|
||||
import {
|
||||
type WholesaleCustomer,
|
||||
type WholesaleProduct,
|
||||
deleteWholesaleCustomer,
|
||||
saveWholesaleCustomer,
|
||||
} from "@/actions/wholesale";
|
||||
import { approveWholesaleRegistration } from "@/actions/wholesale-register";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
|
||||
|
||||
import CustomerFormSection from "./CustomerForm";
|
||||
import {
|
||||
type CustomerForm,
|
||||
EMPTY_CUSTOMER_FORM,
|
||||
customerFormFromCustomer,
|
||||
} from "./customerFormState";
|
||||
import CustomerListSection from "./CustomerList";
|
||||
import CustomerPricingPanel from "./CustomerPricingPanel";
|
||||
import PriceSheetModal from "./PriceSheetModal";
|
||||
|
||||
type Registration = {
|
||||
id: string; company_name: string | null; contact_name: string | null;
|
||||
email: string; phone: string | null; account_status: string; created_at: string;
|
||||
};
|
||||
|
||||
const EMPTY_REGISTRATIONS: Registration[] = [];
|
||||
|
||||
type State = {
|
||||
showForm: boolean;
|
||||
editing: WholesaleCustomer | null;
|
||||
subTab: "customers" | "registrations";
|
||||
form: CustomerForm;
|
||||
saving: boolean;
|
||||
processingReg: string | null;
|
||||
pricingCustomer: WholesaleCustomer | null;
|
||||
selectedCustomers: Set<string>;
|
||||
sendingPriceSheet: boolean;
|
||||
priceSheetTarget: { customerIds: string[]; defaultSubject: string } | null;
|
||||
openCustomerActions: string | null;
|
||||
deletingCustomer: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "set_show_form"; showForm: boolean }
|
||||
| { type: "set_sub_tab"; subTab: "customers" | "registrations" }
|
||||
| { type: "set_form_field"; field: keyof CustomerForm; value: CustomerForm[keyof CustomerForm] }
|
||||
| { type: "open_new" }
|
||||
| { type: "open_edit"; customer: WholesaleCustomer }
|
||||
| { type: "set_saving"; saving: boolean }
|
||||
| { type: "set_processing_reg"; processingReg: string | null }
|
||||
| { type: "set_pricing_customer"; pricingCustomer: WholesaleCustomer | null }
|
||||
| { type: "toggle_selected"; id: string }
|
||||
| { type: "toggle_all"; customerIds: string[] }
|
||||
| { type: "clear_selected" }
|
||||
| { type: "set_sending_price_sheet"; sending: boolean }
|
||||
| { type: "set_price_sheet_target"; target: { customerIds: string[]; defaultSubject: string } | null }
|
||||
| { type: "set_open_customer_actions"; id: string | null }
|
||||
| { type: "set_deleting_customer"; id: string | null };
|
||||
|
||||
const initialState: State = {
|
||||
showForm: false,
|
||||
editing: null,
|
||||
subTab: "customers",
|
||||
form: EMPTY_CUSTOMER_FORM,
|
||||
saving: false,
|
||||
processingReg: null,
|
||||
pricingCustomer: null,
|
||||
selectedCustomers: new Set(),
|
||||
sendingPriceSheet: false,
|
||||
priceSheetTarget: null,
|
||||
openCustomerActions: null,
|
||||
deletingCustomer: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "set_show_form":
|
||||
return { ...state, showForm: action.showForm };
|
||||
case "set_sub_tab":
|
||||
return { ...state, subTab: action.subTab };
|
||||
case "set_form_field":
|
||||
return { ...state, form: { ...state.form, [action.field]: action.value } };
|
||||
case "open_new":
|
||||
return { ...state, editing: null, form: EMPTY_CUSTOMER_FORM, showForm: true };
|
||||
case "open_edit":
|
||||
return { ...state, editing: action.customer, form: customerFormFromCustomer(action.customer), showForm: true };
|
||||
case "set_saving":
|
||||
return { ...state, saving: action.saving };
|
||||
case "set_processing_reg":
|
||||
return { ...state, processingReg: action.processingReg };
|
||||
case "set_pricing_customer":
|
||||
return { ...state, pricingCustomer: action.pricingCustomer };
|
||||
case "toggle_selected": {
|
||||
const next = new Set(state.selectedCustomers);
|
||||
if (next.has(action.id)) next.delete(action.id); else next.add(action.id);
|
||||
return { ...state, selectedCustomers: next };
|
||||
}
|
||||
case "toggle_all": {
|
||||
if (state.selectedCustomers.size === action.customerIds.length) {
|
||||
return { ...state, selectedCustomers: new Set() };
|
||||
}
|
||||
return { ...state, selectedCustomers: new Set(action.customerIds) };
|
||||
}
|
||||
case "clear_selected":
|
||||
return { ...state, selectedCustomers: new Set() };
|
||||
case "set_sending_price_sheet":
|
||||
return { ...state, sendingPriceSheet: action.sending };
|
||||
case "set_price_sheet_target":
|
||||
return { ...state, priceSheetTarget: action.target };
|
||||
case "set_open_customer_actions":
|
||||
return { ...state, openCustomerActions: action.id };
|
||||
case "set_deleting_customer":
|
||||
return { ...state, deletingCustomer: action.id };
|
||||
}
|
||||
}
|
||||
|
||||
export default function CustomersTab({
|
||||
customers,
|
||||
products,
|
||||
brandId,
|
||||
onMsg,
|
||||
registrations = EMPTY_REGISTRATIONS,
|
||||
onRefresh,
|
||||
}: {
|
||||
customers: WholesaleCustomer[];
|
||||
products: WholesaleProduct[];
|
||||
brandId: string;
|
||||
onMsg: (kind: "success" | "error", text: string) => void;
|
||||
registrations?: Registration[];
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
// Close customer actions dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!(e.target as Element).closest(".customer-actions-cell")) {
|
||||
dispatch({ type: "set_open_customer_actions", id: null });
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
async function handleApproveReject(regId: string, action: "approve" | "reject") {
|
||||
dispatch({ type: "set_processing_reg", processingReg: regId });
|
||||
const result = await approveWholesaleRegistration(regId, brandId, action);
|
||||
dispatch({ type: "set_processing_reg", processingReg: null });
|
||||
if (result.success) {
|
||||
onMsg("success", action === "approve" ? "Registration approved." : "Registration rejected.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to process registration.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCustomer(customerId: string) {
|
||||
if (!confirm("Delete this customer? This cannot be undone. Customers with existing orders cannot be deleted.")) return;
|
||||
dispatch({ type: "set_deleting_customer", id: customerId });
|
||||
const result = await deleteWholesaleCustomer(customerId);
|
||||
dispatch({ type: "set_deleting_customer", id: null });
|
||||
if (result.success) {
|
||||
onMsg("success", "Customer deleted.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to delete customer.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
dispatch({ type: "set_saving", saving: true });
|
||||
const result = await saveWholesaleCustomer({
|
||||
brandId,
|
||||
userId: state.editing?.user_id ?? undefined,
|
||||
companyName: state.form.companyName || undefined,
|
||||
contactName: state.form.contactName || undefined,
|
||||
email: state.form.email || undefined,
|
||||
phone: state.form.phone || undefined,
|
||||
accountStatus: state.form.accountStatus,
|
||||
creditLimit: state.form.creditLimit,
|
||||
depositsEnabled: state.form.depositsEnabled,
|
||||
depositThreshold: state.form.depositThreshold ? Number(state.form.depositThreshold) : undefined,
|
||||
depositPercentage: state.form.depositPercentage ? Number(state.form.depositPercentage) : undefined,
|
||||
orderEmail: state.form.orderEmail || undefined,
|
||||
invoiceEmail: state.form.invoiceEmail || undefined,
|
||||
adminNotes: state.form.adminNotes || undefined,
|
||||
});
|
||||
dispatch({ type: "set_saving", saving: false });
|
||||
if (result.success) {
|
||||
onMsg("success", state.editing ? "Customer updated." : "Customer created.");
|
||||
dispatch({ type: "set_show_form", showForm: false });
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to save.");
|
||||
}
|
||||
}
|
||||
|
||||
function openPriceSheetModal(customerIds: string[]) {
|
||||
if (customerIds.length === 0) return;
|
||||
dispatch({
|
||||
type: "set_price_sheet_target",
|
||||
target: {
|
||||
customerIds,
|
||||
defaultSubject: `Wholesale Price Sheet — ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleConfirmPriceSheet(subject: string, customNote: string) {
|
||||
if (!state.priceSheetTarget) return;
|
||||
dispatch({ type: "set_sending_price_sheet", sending: true });
|
||||
dispatch({ type: "set_price_sheet_target", target: null });
|
||||
try {
|
||||
const res = await fetch("/api/wholesale/price-sheet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customerIds: state.priceSheetTarget.customerIds,
|
||||
brandId,
|
||||
subject,
|
||||
customNote: customNote || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
onMsg("success", `Price sheet sent to ${data.enqueued} customer(s).`);
|
||||
dispatch({ type: "clear_selected" });
|
||||
} else {
|
||||
onMsg("error", data.error ?? "Failed to send price sheet.");
|
||||
}
|
||||
} catch {
|
||||
onMsg("error", "Failed to send price sheet.");
|
||||
} finally {
|
||||
dispatch({ type: "set_sending_price_sheet", sending: false });
|
||||
}
|
||||
}
|
||||
|
||||
const allCustomerIds = customers.map(c => c.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Sub-tab nav */}
|
||||
<div className="flex items-center gap-4">
|
||||
<AdminButton
|
||||
variant={state.subTab === "customers" ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "set_sub_tab", subTab: "customers" })}
|
||||
>
|
||||
Customers ({customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length})
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant={state.subTab === "registrations" ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "set_sub_tab", subTab: "registrations" })}
|
||||
>
|
||||
Registrations ({registrations.filter(r => r.account_status === "pending_approval").length})
|
||||
</AdminButton>
|
||||
{state.subTab === "customers" && (
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "open_new" })}
|
||||
className="ml-auto"
|
||||
>
|
||||
+ Add Customer
|
||||
</AdminButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.subTab === "registrations" && (
|
||||
<RegistrationsSection
|
||||
registrations={registrations}
|
||||
processingReg={state.processingReg}
|
||||
onApproveReject={handleApproveReject}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.subTab === "customers" && (
|
||||
<>
|
||||
{state.showForm && (
|
||||
<CustomerFormSection
|
||||
editing={state.editing}
|
||||
form={state.form}
|
||||
saving={state.saving}
|
||||
onFieldChange={(field, value) => dispatch({ type: "set_form_field", field, value })}
|
||||
onSave={handleSave}
|
||||
onCancel={() => dispatch({ type: "set_show_form", showForm: false })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomerListSection
|
||||
customers={customers}
|
||||
selectedCustomers={state.selectedCustomers}
|
||||
allCustomerIds={allCustomerIds}
|
||||
openCustomerActions={state.openCustomerActions}
|
||||
deletingCustomer={state.deletingCustomer}
|
||||
sendingPriceSheet={state.sendingPriceSheet}
|
||||
onToggle={(id) => dispatch({ type: "toggle_selected", id })}
|
||||
onToggleAll={() => dispatch({ type: "toggle_all", customerIds: allCustomerIds })}
|
||||
onToggleActions={(id) => dispatch({ type: "set_open_customer_actions", id: state.openCustomerActions === id ? null : id })}
|
||||
onCloseActions={() => dispatch({ type: "set_open_customer_actions", id: null })}
|
||||
onEdit={(c) => dispatch({ type: "open_edit", customer: c })}
|
||||
onPricing={(c) => dispatch({ type: "set_pricing_customer", pricingCustomer: c })}
|
||||
onDelete={handleDeleteCustomer}
|
||||
onSendPriceSheet={openPriceSheetModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Customer Pricing Overlays Panel */}
|
||||
{state.pricingCustomer && (
|
||||
<CustomerPricingPanel
|
||||
customer={state.pricingCustomer}
|
||||
products={products}
|
||||
onClose={() => dispatch({ type: "set_pricing_customer", pricingCustomer: null })}
|
||||
onMsg={onMsg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Price Sheet Modal */}
|
||||
{state.priceSheetTarget && (
|
||||
<PriceSheetModal
|
||||
customerCount={state.priceSheetTarget.customerIds.length}
|
||||
defaultSubject={state.priceSheetTarget.defaultSubject}
|
||||
onConfirm={handleConfirmPriceSheet}
|
||||
onClose={() => {
|
||||
dispatch({ type: "set_price_sheet_target", target: null });
|
||||
dispatch({ type: "set_sending_price_sheet", sending: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Subcomponents ─────────────────────────────────────────────────────────────
|
||||
|
||||
type RegistrationsSectionProps = {
|
||||
registrations: Registration[];
|
||||
processingReg: string | null;
|
||||
onApproveReject: (regId: string, action: "approve" | "reject") => void;
|
||||
};
|
||||
|
||||
function RegistrationsSection({ registrations, processingReg, onApproveReject }: RegistrationsSectionProps) {
|
||||
const pending = registrations.filter(r => r.account_status === "pending_approval");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-secondary)]">Pending Registrations</h3>
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-sm text-[var(--admin-text-muted)] py-4 text-center">No pending registrations.</p>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-semibold text-left">Company</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Contact</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Registered</th>
|
||||
<th className="px-5 py-3" aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{pending.map(r => (
|
||||
<tr key={r.id} className="hover:bg-[var(--admin-bg-subtle)]">
|
||||
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{r.company_name ?? "—"}</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{r.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{r.email}</span></td>
|
||||
<td className="px-5 py-3">
|
||||
<AdminBadge variant="warning">Pending</AdminBadge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-muted)] text-xs">{formatDate(new Date(r.created_at))}</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex gap-2">
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onApproveReject(r.id, "approve")}
|
||||
disabled={processingReg === r.id}
|
||||
isLoading={processingReg === r.id}
|
||||
>
|
||||
{processingReg === r.id ? "..." : "Approve"}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onApproveReject(r.id, "reject")}
|
||||
disabled={processingReg === r.id}
|
||||
>
|
||||
Reject
|
||||
</AdminButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type WholesaleOrder,
|
||||
type WholesaleDashboardStats,
|
||||
} from "@/actions/wholesale";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export type DashboardWebhookActivity = Array<{
|
||||
id: string; event_type: string; order_id: string | null;
|
||||
status: string; attempts: number; created_at: string; response: string | null;
|
||||
}>;
|
||||
|
||||
export default function DashboardTab({
|
||||
stats,
|
||||
recentOrders,
|
||||
brandId: _brandId,
|
||||
onMsg: _onMsg,
|
||||
webhookActivity,
|
||||
}: {
|
||||
stats: WholesaleDashboardStats;
|
||||
recentOrders: WholesaleOrder[];
|
||||
brandId: string;
|
||||
onMsg: (kind: "success" | "error", text: string) => void;
|
||||
webhookActivity: DashboardWebhookActivity;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{[
|
||||
{ label: "Open Orders", value: stats.open_orders, variant: "default" },
|
||||
{ label: "Pickup Today", value: stats.pickup_today, variant: "default" },
|
||||
{ label: "Past Due", value: stats.past_due, variant: "danger" },
|
||||
{ label: "Total Unpaid", value: `$${stats.total_unpaid.toFixed(2)}`, variant: "default" },
|
||||
{ label: "Awaiting Deposit", value: stats.awaiting_deposit, variant: "warning" },
|
||||
{ label: "Fulfilled Today", value: stats.fulfilled_today, variant: "success" },
|
||||
].map((card) => (
|
||||
<div key={card.label} className={`rounded-xl border shadow-sm ${card.variant === "danger" ? "bg-[var(--admin-danger-light)] border-[var(--admin-danger)]" : card.variant === "warning" ? "bg-[var(--admin-warning-light)] border-[var(--admin-warning)]" : card.variant === "success" ? "bg-[var(--admin-accent-light)] border-[var(--admin-accent)]" : "bg-white border-[var(--admin-border)]"}`}>
|
||||
<p className={`text-xs ${card.variant === "danger" ? "text-[var(--admin-danger)]" : card.variant === "warning" ? "text-[var(--admin-warning)]" : card.variant === "success" ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-muted)]"}`}>{card.label}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent orders */}
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Orders</h2>
|
||||
{recentOrders.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-base font-semibold text-slate-600 mb-1">No wholesale orders yet</p>
|
||||
<p className="text-sm text-slate-400">Wholesale orders placed by your customers will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--admin-text-muted)] border-b">
|
||||
<th className="pb-3 font-medium">Invoice</th>
|
||||
<th className="pb-3 font-medium">Customer</th>
|
||||
<th className="pb-3 font-medium">Pickup Date</th>
|
||||
<th className="pb-3 font-medium text-right">Total</th>
|
||||
<th className="pb-3 font-medium">Status</th>
|
||||
<th className="pb-3 font-medium">Payment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{recentOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-[var(--admin-bg-subtle)]">
|
||||
<td className="py-3 font-mono text-xs text-[var(--admin-text-muted)]">{order.invoice_number ?? "—"}</td>
|
||||
<td className="py-3 font-medium text-[var(--admin-text-primary)]">{order.company_name}</td>
|
||||
<td className="py-3 text-[var(--admin-text-secondary)]">{order.anticipated_pickup_date ?? "—"}</td>
|
||||
<td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(order.subtotal).toFixed(2)}</td>
|
||||
<td className="py-3">
|
||||
<StatusBadge status={order.status} />
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span className={`text-xs font-medium ${
|
||||
order.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"
|
||||
}`}>
|
||||
{order.payment_status === "paid" ? "Paid" : order.balance_due > 0 ? `$${Number(order.balance_due).toFixed(2)} due` : "Partial"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent webhook activity */}
|
||||
{webhookActivity.length > 0 && (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Webhook Activity</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--admin-text-muted)] border-b">
|
||||
<th className="pb-3 font-medium">Event</th>
|
||||
<th className="pb-3 font-medium">Order</th>
|
||||
<th className="pb-3 font-medium">Status</th>
|
||||
<th className="pb-3 font-medium">Attempts</th>
|
||||
<th className="pb-3 font-medium">Sent At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{webhookActivity.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-[var(--admin-bg-subtle)]">
|
||||
<td className="py-3">
|
||||
<span className="font-mono text-xs bg-[var(--admin-accent)] text-white px-2 py-0.5 rounded">{entry.event_type}</span>
|
||||
</td>
|
||||
<td className="py-3 font-mono text-xs text-[var(--admin-text-muted)]">{entry.order_id ? entry.order_id.slice(0, 8) : "—"}</td>
|
||||
<td className="py-3">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
entry.status === "sent" ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" :
|
||||
entry.status === "failed" ? "bg-[var(--admin-danger-light)] text-[var(--admin-danger)]" :
|
||||
entry.status === "retrying" ? "bg-[var(--admin-warning-light)] text-[var(--admin-warning)]" :
|
||||
"bg-[var(--admin-border)] text-[var(--admin-text-secondary)]"
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-[var(--admin-text-muted)]">{entry.attempts}</td>
|
||||
<td className="py-3 text-[var(--admin-text-muted)]">{new Date(entry.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
export default function OrdersBulkActionsBar({
|
||||
selectedCount,
|
||||
bulkLoading,
|
||||
onBulkFulfill,
|
||||
onOpenBulkDeposit,
|
||||
onClearSelection,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
bulkLoading: boolean;
|
||||
onBulkFulfill: () => void;
|
||||
onOpenBulkDeposit: () => void;
|
||||
onClearSelection: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-4 py-3">
|
||||
<span className="text-sm font-medium text-[var(--admin-accent-text)]">{selectedCount} selected</span>
|
||||
<AdminButton variant="primary" size="sm" onClick={onBulkFulfill} disabled={bulkLoading} isLoading={bulkLoading}>
|
||||
{bulkLoading ? "..." : "Bulk Fulfill"}
|
||||
</AdminButton>
|
||||
<AdminButton variant="secondary" size="sm" onClick={onOpenBulkDeposit}>
|
||||
Bulk Record Deposit
|
||||
</AdminButton>
|
||||
<button type="button" onClick={onClearSelection} className="ml-auto text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Clear selection</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
export default function OrdersBulkDepositModal({
|
||||
selectedCount,
|
||||
bulkDepAmount,
|
||||
bulkDepMethod,
|
||||
bulkLoading,
|
||||
onAmountChange,
|
||||
onMethodChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
bulkDepAmount: string;
|
||||
bulkDepMethod: string;
|
||||
bulkLoading: boolean;
|
||||
onAmountChange: (v: string) => void;
|
||||
onMethodChange: (v: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Bulk Record Deposit ({selectedCount} orders)</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="fld-10-amount-per-order" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount per order ($)</label>
|
||||
<input id="fld-10-amount-per-order" aria-label="0.00" type="number" value={bulkDepAmount} onChange={e => onAmountChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-11-method-2" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
|
||||
<select id="fld-11-method-2" aria-label="Select" value={bulkDepMethod} onChange={e => onMethodChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="cash">Cash</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="wire">Wire</option>
|
||||
<option value="card">Card</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<AdminButton onClick={onConfirm} variant="primary" disabled={!bulkDepAmount || bulkLoading} isLoading={bulkLoading}>
|
||||
{bulkLoading ? "Recording..." : "Record on All"}
|
||||
</AdminButton>
|
||||
<AdminButton onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
export default function OrdersDepositModal({
|
||||
depAmount,
|
||||
depMethod,
|
||||
onAmountChange,
|
||||
onMethodChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
depAmount: string;
|
||||
depMethod: string;
|
||||
onAmountChange: (v: string) => void;
|
||||
onMethodChange: (v: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Record Deposit</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="fld-8-amount" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount ($)</label>
|
||||
<input id="fld-8-amount" aria-label="Number" type="number" value={depAmount} onChange={e => onAmountChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-9-method" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
|
||||
<select id="fld-9-method" aria-label="Select" value={depMethod} onChange={e => onMethodChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="cash">Cash</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="wire">Wire</option>
|
||||
<option value="card">Card</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<AdminButton onClick={onConfirm} variant="primary">
|
||||
Record Deposit
|
||||
</AdminButton>
|
||||
<AdminButton onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { AdminSearchInput } from "@/components/admin/design-system";
|
||||
|
||||
export type OrdersFilters = {
|
||||
statusFilter: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export default function OrdersFilterBar({
|
||||
filters,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
onStatusChange,
|
||||
onDateFromChange,
|
||||
onDateToChange,
|
||||
onSearchChange,
|
||||
onSearchClear,
|
||||
}: {
|
||||
filters: OrdersFilters;
|
||||
filteredCount: number;
|
||||
totalCount: number;
|
||||
onStatusChange: (v: string) => void;
|
||||
onDateFromChange: (v: string) => void;
|
||||
onDateToChange: (v: string) => void;
|
||||
onSearchChange: (v: string) => void;
|
||||
onSearchClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-2xl bg-white border border-[var(--admin-border)] p-4 shadow-sm">
|
||||
<div>
|
||||
<label htmlFor="fld-5-status" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
|
||||
<select id="fld-5-status" aria-label="Select" value={filters.statusFilter} onChange={e => onStatusChange(e.target.value)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="awaiting_deposit">Awaiting Deposit</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="fulfilled">Fulfilled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-6-pickup-from" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup From</label>
|
||||
<input id="fld-6-pickup-from" aria-label="Date" type="date" value={filters.dateFrom} onChange={e => onDateFromChange(e.target.value)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-7-pickup-to" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup To</label>
|
||||
<input id="fld-7-pickup-to" aria-label="Date" type="date" value={filters.dateTo} onChange={e => onDateToChange(e.target.value)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1" htmlFor="fld-search">Search</label>
|
||||
<AdminSearchInput
|
||||
value={filters.searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
onClear={onSearchClear}
|
||||
placeholder="Invoice, customer, email..."
|
||||
/>
|
||||
</div>
|
||||
{filteredCount !== totalCount && (
|
||||
<div className="self-end pb-1">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">{filteredCount} of {totalCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
export default function OrdersManifestSection({
|
||||
manifestLoading,
|
||||
onGenerateManifest,
|
||||
}: {
|
||||
manifestLoading: boolean;
|
||||
onGenerateManifest: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-4 flex items-center gap-4 shadow-sm">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Load Manifest</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Generate a printable manifest for pending/active orders.</p>
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onGenerateManifest}
|
||||
disabled={manifestLoading}
|
||||
isLoading={manifestLoading}
|
||||
>
|
||||
{manifestLoading ? "Generating..." : "Generate Manifest"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useEffect, useRef } from "react";
|
||||
import {
|
||||
type WholesaleOrder,
|
||||
type WholesaleCustomer,
|
||||
bulkFulfillWholesaleOrders,
|
||||
bulkRecordWholesaleDeposit,
|
||||
recordWholesaleDeposit,
|
||||
} from "@/actions/wholesale";
|
||||
import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal";
|
||||
|
||||
import OrdersFilterBar from "./OrdersFilterBar";
|
||||
import OrdersBulkActionsBar from "./OrdersBulkActionsBar";
|
||||
import OrdersManifestSection from "./OrdersManifestSection";
|
||||
import OrdersTable from "./OrdersTable";
|
||||
import OrdersDepositModal from "./OrdersDepositModal";
|
||||
import OrdersBulkDepositModal from "./OrdersBulkDepositModal";
|
||||
import { fulfillWithNotifications, openManifestWindow } from "./ordersActions";
|
||||
|
||||
type State = {
|
||||
showDepForm: string | null;
|
||||
depAmount: string;
|
||||
depMethod: string;
|
||||
fulfilling: string | null;
|
||||
manifestLoading: boolean;
|
||||
selected: Set<string>;
|
||||
statusFilter: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
searchQuery: string;
|
||||
bulkDepAmount: string;
|
||||
bulkDepMethod: string;
|
||||
showBulkDep: boolean;
|
||||
bulkLoading: boolean;
|
||||
openActions: string | null;
|
||||
showViewOrder: WholesaleOrder | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "set_show_dep_form"; id: string | null }
|
||||
| { type: "set_dep_amount"; amount: string }
|
||||
| { type: "set_dep_method"; method: string }
|
||||
| { type: "set_fulfilling"; id: string | null }
|
||||
| { type: "set_manifest_loading"; loading: boolean }
|
||||
| { type: "toggle_selected"; id: string }
|
||||
| { type: "toggle_all"; ids: string[] }
|
||||
| { type: "clear_selected" }
|
||||
| { type: "set_status_filter"; value: string }
|
||||
| { type: "set_date_from"; value: string }
|
||||
| { type: "set_date_to"; value: string }
|
||||
| { type: "set_search_query"; value: string }
|
||||
| { type: "set_bulk_dep_amount"; amount: string }
|
||||
| { type: "set_bulk_dep_method"; method: string }
|
||||
| { type: "set_show_bulk_dep"; show: boolean }
|
||||
| { type: "set_bulk_loading"; loading: boolean }
|
||||
| { type: "set_open_actions"; id: string | null }
|
||||
| { type: "set_show_view_order"; order: WholesaleOrder | null };
|
||||
|
||||
const initialState: State = {
|
||||
showDepForm: null,
|
||||
depAmount: "",
|
||||
depMethod: "cash",
|
||||
fulfilling: null,
|
||||
manifestLoading: false,
|
||||
selected: new Set(),
|
||||
statusFilter: "all",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
searchQuery: "",
|
||||
bulkDepAmount: "",
|
||||
bulkDepMethod: "cash",
|
||||
showBulkDep: false,
|
||||
bulkLoading: false,
|
||||
openActions: null,
|
||||
showViewOrder: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "set_show_dep_form":
|
||||
return { ...state, showDepForm: action.id };
|
||||
case "set_dep_amount":
|
||||
return { ...state, depAmount: action.amount };
|
||||
case "set_dep_method":
|
||||
return { ...state, depMethod: action.method };
|
||||
case "set_fulfilling":
|
||||
return { ...state, fulfilling: action.id };
|
||||
case "set_manifest_loading":
|
||||
return { ...state, manifestLoading: action.loading };
|
||||
case "toggle_selected": {
|
||||
const next = new Set(state.selected);
|
||||
if (next.has(action.id)) next.delete(action.id); else next.add(action.id);
|
||||
return { ...state, selected: next };
|
||||
}
|
||||
case "toggle_all": {
|
||||
if (state.selected.size === action.ids.length) {
|
||||
return { ...state, selected: new Set() };
|
||||
}
|
||||
return { ...state, selected: new Set(action.ids) };
|
||||
}
|
||||
case "clear_selected":
|
||||
return { ...state, selected: new Set() };
|
||||
case "set_status_filter":
|
||||
return { ...state, statusFilter: action.value };
|
||||
case "set_date_from":
|
||||
return { ...state, dateFrom: action.value };
|
||||
case "set_date_to":
|
||||
return { ...state, dateTo: action.value };
|
||||
case "set_search_query":
|
||||
return { ...state, searchQuery: action.value };
|
||||
case "set_bulk_dep_amount":
|
||||
return { ...state, bulkDepAmount: action.amount };
|
||||
case "set_bulk_dep_method":
|
||||
return { ...state, bulkDepMethod: action.method };
|
||||
case "set_show_bulk_dep":
|
||||
return { ...state, showBulkDep: action.show };
|
||||
case "set_bulk_loading":
|
||||
return { ...state, bulkLoading: action.loading };
|
||||
case "set_open_actions":
|
||||
return { ...state, openActions: action.id };
|
||||
case "set_show_view_order":
|
||||
return { ...state, showViewOrder: action.order };
|
||||
}
|
||||
}
|
||||
|
||||
export default function OrdersTab({ orders, customers, brandId, onMsg, onRefresh }: {
|
||||
orders: WholesaleOrder[];
|
||||
customers: WholesaleCustomer[];
|
||||
brandId: string;
|
||||
onMsg: (kind: "success" | "error", text: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const deletingRef = useRef<string | null>(null);
|
||||
|
||||
const filtered = orders.filter(o => {
|
||||
if (state.statusFilter !== "all" && o.status !== state.statusFilter) return false;
|
||||
if (state.dateFrom && (!o.anticipated_pickup_date || o.anticipated_pickup_date < state.dateFrom)) return false;
|
||||
if (state.dateTo && (!o.anticipated_pickup_date || o.anticipated_pickup_date > state.dateTo)) return false;
|
||||
if (state.searchQuery) {
|
||||
const q = state.searchQuery.toLowerCase();
|
||||
if (!(o.company_name?.toLowerCase().includes(q) || o.invoice_number?.toLowerCase().includes(q) || o.customer_email?.toLowerCase().includes(q))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Close actions dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!(e.target as Element).closest(".actions-cell")) {
|
||||
dispatch({ type: "set_open_actions", id: null });
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
async function handleBulkFulfill() {
|
||||
if (state.selected.size === 0) return;
|
||||
dispatch({ type: "set_bulk_loading", loading: true });
|
||||
const result = await bulkFulfillWholesaleOrders(Array.from(state.selected));
|
||||
dispatch({ type: "set_bulk_loading", loading: false });
|
||||
if (result.success) {
|
||||
onMsg("success", `${result.count} order(s) marked as fulfilled.`);
|
||||
dispatch({ type: "clear_selected" });
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Bulk fulfill failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkDeposit() {
|
||||
if (state.selected.size === 0 || !state.bulkDepAmount) return;
|
||||
dispatch({ type: "set_bulk_loading", loading: true });
|
||||
const result = await bulkRecordWholesaleDeposit(Array.from(state.selected), Number(state.bulkDepAmount), state.bulkDepMethod);
|
||||
dispatch({ type: "set_bulk_loading", loading: false });
|
||||
if (result.success) {
|
||||
onMsg("success", `Deposit recorded on ${result.count} order(s).`);
|
||||
dispatch({ type: "clear_selected" });
|
||||
dispatch({ type: "set_show_bulk_dep", show: false });
|
||||
dispatch({ type: "set_bulk_dep_amount", amount: "" });
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Bulk deposit failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFulfill(orderId: string) {
|
||||
dispatch({ type: "set_fulfilling", id: orderId });
|
||||
const result = await fulfillWithNotifications(orderId, orders, customers, brandId);
|
||||
dispatch({ type: "set_fulfilling", id: null });
|
||||
if (result.success) {
|
||||
onMsg("success", "Order marked as fulfilled.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to fulfill order.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRecordDeposit(orderId: string) {
|
||||
const result = await recordWholesaleDeposit(orderId, Number(state.depAmount), state.depMethod);
|
||||
dispatch({ type: "set_show_dep_form", id: null });
|
||||
dispatch({ type: "set_dep_amount", amount: "" });
|
||||
if (result.success) {
|
||||
onMsg("success", "Deposit recorded.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to record deposit.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelOrder(orderId: string) {
|
||||
if (!confirm("Cancel this order? This cannot be undone.")) return;
|
||||
const { updateWholesaleOrderStatus } = await import("@/actions/wholesale");
|
||||
const result = await updateWholesaleOrderStatus(orderId, "cancelled");
|
||||
if (result.success) {
|
||||
onMsg("success", "Order cancelled.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to cancel order.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteOrder(orderId: string) {
|
||||
if (!confirm("Delete this order? Fulfilled and paid orders cannot be deleted. This cannot be undone.")) return;
|
||||
deletingRef.current = orderId;
|
||||
const { deleteWholesaleOrder } = await import("@/actions/wholesale");
|
||||
const result = await deleteWholesaleOrder(orderId);
|
||||
deletingRef.current = null;
|
||||
if (result.success) {
|
||||
onMsg("success", "Order deleted.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to delete order.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerateManifest() {
|
||||
dispatch({ type: "set_manifest_loading", loading: true });
|
||||
const pending = filtered.filter(o => o.fulfillment_status !== "fulfilled");
|
||||
if (pending.length === 0) {
|
||||
dispatch({ type: "set_manifest_loading", loading: false });
|
||||
return;
|
||||
}
|
||||
openManifestWindow(pending, brandId);
|
||||
dispatch({ type: "set_manifest_loading", loading: false });
|
||||
}
|
||||
|
||||
function handleGenerateManifestForOrder(o: WholesaleOrder) {
|
||||
openManifestWindow([o], brandId);
|
||||
}
|
||||
|
||||
function handleSendPriceSheetForOrder(o: WholesaleOrder) {
|
||||
fetch("/api/wholesale/price-sheet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customerIds: [o.customer_id], brandId }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => onMsg("success", `Price sheet sent to customer.`))
|
||||
.catch(() => onMsg("error", "Failed to send price sheet."));
|
||||
}
|
||||
|
||||
const filteredIds = filtered.map(o => o.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Orders ({orders.length})</h2>
|
||||
|
||||
{/* Filter bar */}
|
||||
<OrdersFilterBar
|
||||
filters={{
|
||||
statusFilter: state.statusFilter,
|
||||
dateFrom: state.dateFrom,
|
||||
dateTo: state.dateTo,
|
||||
searchQuery: state.searchQuery,
|
||||
}}
|
||||
filteredCount={filtered.length}
|
||||
totalCount={orders.length}
|
||||
onStatusChange={(v) => dispatch({ type: "set_status_filter", value: v })}
|
||||
onDateFromChange={(v) => dispatch({ type: "set_date_from", value: v })}
|
||||
onDateToChange={(v) => dispatch({ type: "set_date_to", value: v })}
|
||||
onSearchChange={(v) => dispatch({ type: "set_search_query", value: v })}
|
||||
onSearchClear={() => dispatch({ type: "set_search_query", value: "" })}
|
||||
/>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{state.selected.size > 0 && (
|
||||
<OrdersBulkActionsBar
|
||||
selectedCount={state.selected.size}
|
||||
bulkLoading={state.bulkLoading}
|
||||
onBulkFulfill={handleBulkFulfill}
|
||||
onOpenBulkDeposit={() => dispatch({ type: "set_show_bulk_dep", show: true })}
|
||||
onClearSelection={() => dispatch({ type: "clear_selected" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Manifest section */}
|
||||
<OrdersManifestSection
|
||||
manifestLoading={state.manifestLoading}
|
||||
onGenerateManifest={handleGenerateManifest}
|
||||
/>
|
||||
|
||||
<OrdersTable
|
||||
orders={filtered}
|
||||
selected={state.selected}
|
||||
fulfilling={state.fulfilling}
|
||||
openActions={state.openActions}
|
||||
onToggleSelect={(id) => dispatch({ type: "toggle_selected", id })}
|
||||
onToggleAll={() => dispatch({ type: "toggle_all", ids: filteredIds })}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={(id) => dispatch({ type: "set_show_dep_form", id })}
|
||||
onToggleActions={(id) => dispatch({ type: "set_open_actions", id: state.openActions === id ? null : id })}
|
||||
onCloseActions={() => dispatch({ type: "set_open_actions", id: null })}
|
||||
onViewOrder={(o) => dispatch({ type: "set_show_view_order", order: o })}
|
||||
onGenerateManifestForOrder={handleGenerateManifestForOrder}
|
||||
onSendPriceSheetForOrder={handleSendPriceSheetForOrder}
|
||||
onCancelOrder={handleCancelOrder}
|
||||
onDeleteOrder={handleDeleteOrder}
|
||||
/>
|
||||
|
||||
{/* Deposit modal */}
|
||||
{state.showDepForm && (
|
||||
<OrdersDepositModal
|
||||
depAmount={state.depAmount}
|
||||
depMethod={state.depMethod}
|
||||
onAmountChange={(v) => dispatch({ type: "set_dep_amount", amount: v })}
|
||||
onMethodChange={(v) => dispatch({ type: "set_dep_method", method: v })}
|
||||
onConfirm={() => handleRecordDeposit(state.showDepForm!)}
|
||||
onCancel={() => dispatch({ type: "set_show_dep_form", id: null })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk Deposit modal */}
|
||||
{state.showBulkDep && (
|
||||
<OrdersBulkDepositModal
|
||||
selectedCount={state.selected.size}
|
||||
bulkDepAmount={state.bulkDepAmount}
|
||||
bulkDepMethod={state.bulkDepMethod}
|
||||
bulkLoading={state.bulkLoading}
|
||||
onAmountChange={(v) => dispatch({ type: "set_bulk_dep_amount", amount: v })}
|
||||
onMethodChange={(v) => dispatch({ type: "set_bulk_dep_method", method: v })}
|
||||
onConfirm={handleBulkDeposit}
|
||||
onCancel={() => dispatch({ type: "set_show_bulk_dep", show: false })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Order Details modal */}
|
||||
{state.showViewOrder && (
|
||||
<OrderDetailsModal
|
||||
order={state.showViewOrder}
|
||||
onClose={() => dispatch({ type: "set_show_view_order", order: null })}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={(id) => {
|
||||
dispatch({ type: "set_show_view_order", order: null });
|
||||
dispatch({ type: "set_show_dep_form", id });
|
||||
}}
|
||||
fulfilling={state.fulfilling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type WholesaleOrder,
|
||||
} from "@/actions/wholesale";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export default function OrdersTable({
|
||||
orders,
|
||||
selected,
|
||||
fulfilling,
|
||||
openActions,
|
||||
onToggleSelect,
|
||||
onToggleAll,
|
||||
onFulfill,
|
||||
onRecordDeposit,
|
||||
onToggleActions,
|
||||
onCloseActions,
|
||||
onViewOrder,
|
||||
onGenerateManifestForOrder,
|
||||
onSendPriceSheetForOrder,
|
||||
onCancelOrder,
|
||||
onDeleteOrder,
|
||||
}: {
|
||||
orders: WholesaleOrder[];
|
||||
selected: Set<string>;
|
||||
fulfilling: string | null;
|
||||
openActions: string | null;
|
||||
onToggleSelect: (id: string) => void;
|
||||
onToggleAll: () => void;
|
||||
onFulfill: (id: string) => void;
|
||||
onRecordDeposit: (id: string) => void;
|
||||
onToggleActions: (id: string) => void;
|
||||
onCloseActions: () => void;
|
||||
onViewOrder: (o: WholesaleOrder) => void;
|
||||
onGenerateManifestForOrder: (o: WholesaleOrder) => void;
|
||||
onSendPriceSheetForOrder: (o: WholesaleOrder) => void;
|
||||
onCancelOrder: (id: string) => void;
|
||||
onDeleteOrder: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-semibold text-left w-10">
|
||||
<input id="fld-search" aria-label="Checkbox" type="checkbox" checked={selected.size === orders.length && orders.length > 0} onChange={onToggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Invoice</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Customer</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Pickup Date</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Subtotal</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Deposit</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Balance</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Payment</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{orders.length === 0 ? (
|
||||
<tr><td colSpan={10} className="py-8 text-center text-[var(--admin-text-muted)]">No orders match the current filters.</td></tr>
|
||||
) : orders.map(o => (
|
||||
<tr key={o.id} className={`hover:bg-[var(--admin-bg-subtle)] ${selected.has(o.id) ? "bg-[var(--admin-accent-light)]" : ""}`}>
|
||||
<td className="px-5 py-3">
|
||||
<input aria-label="Checkbox" type="checkbox" checked={selected.has(o.id)} onChange={() => onToggleSelect(o.id)} className="rounded" />
|
||||
</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-[var(--admin-text-muted)]">{o.invoice_number ?? "—"}</td>
|
||||
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{o.company_name}</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{o.anticipated_pickup_date ?? "—"}</td>
|
||||
<td className="px-5 py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(o.subtotal).toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-right text-[var(--admin-text-secondary)]">${Number(o.deposit_paid).toFixed(2)} / ${Number(o.deposit_required).toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<span className={Number(o.balance_due) > 0 ? "text-[var(--admin-warning)] font-medium" : "text-[var(--admin-accent)]"}>
|
||||
${Number(o.balance_due).toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3"><StatusBadge status={o.status} /></td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`text-xs font-medium ${o.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"}`}>
|
||||
{o.payment_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 actions-cell relative">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Primary: Mark Fulfilled — only when order is not yet fulfilled */}
|
||||
{o.fulfillment_status !== "fulfilled" && (
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => !fulfilling && onFulfill(o.id)}
|
||||
disabled={fulfilling === o.id}
|
||||
isLoading={fulfilling === o.id}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
{fulfilling === o.id ? "..." : "Fulfill"}
|
||||
</AdminButton>
|
||||
)}
|
||||
|
||||
{/* Primary: Record Deposit — when there's a balance due */}
|
||||
{Number(o.balance_due) > 0 && o.fulfillment_status !== "fulfilled" && (
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onRecordDeposit(o.id)}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-9-9h18" /></svg>
|
||||
Deposit
|
||||
</AdminButton>
|
||||
)}
|
||||
|
||||
{/* Invoice download — always visible as icon button */}
|
||||
<a aria-label="Download Invoice"
|
||||
href={`/api/wholesale/invoice/${o.id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer" title="Download Invoice"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 110 12H15M6 2h9l4 4v14a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/></svg>
|
||||
</a>
|
||||
|
||||
{/* ⋮ Actions dropdown — opens upward to avoid table cutoff */}
|
||||
<div className="relative">
|
||||
<button type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleActions(o.id); }}
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
|
||||
{openActions === o.id && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-1 z-30 w-56 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={() => { onCloseActions(); onViewOrder(o); }}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
View / Edit Order
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
onClick={() => { onCloseActions(); onGenerateManifestForOrder(o); }}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 01-18 0 9 9 0 0118 0z"/></svg>
|
||||
Generate Manifest
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
onClick={() => { onCloseActions(); onSendPriceSheetForOrder(o); }}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
Send Price Sheet
|
||||
</button>
|
||||
|
||||
<div className="my-1 border-t border-[var(--admin-border)]" />
|
||||
|
||||
{o.status !== "cancelled" && o.fulfillment_status !== "fulfilled" && (
|
||||
<button type="button"
|
||||
onClick={() => { onCloseActions(); onCancelOrder(o.id); }}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
|
||||
Cancel Order
|
||||
</button>
|
||||
)}
|
||||
|
||||
{o.fulfillment_status !== "fulfilled" && (
|
||||
<button type="button"
|
||||
onClick={() => { onCloseActions(); onDeleteOrder(o.id); }}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete Order
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
export default function PriceSheetModal({
|
||||
customerCount,
|
||||
defaultSubject,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
customerCount: number;
|
||||
defaultSubject: string;
|
||||
onConfirm: (subject: string, customNote: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [subject, setSubject] = useState(defaultSubject);
|
||||
const [note, setNote] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!subject.trim()) return;
|
||||
setSending(true);
|
||||
onConfirm(subject.trim(), note.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Send price sheet"
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]">
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{customerCount === 1 ? "1 customer" : `${customerCount} customers`}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close send price sheet" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="ws-price-subject" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">Subject</label>
|
||||
<input aria-label="Ws Price Subject"
|
||||
id="ws-price-subject"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ws-price-note" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">
|
||||
Add a Note <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea aria-label=". 'Check Out Our New Seasonal Items On Page 2. Place Your Order By Friday For Weekend Pickup.'"
|
||||
id="ws-price-note"
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="e.g. 'Check out our new seasonal items on page 2. Place your order by Friday for weekend pickup.'"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] resize-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
This note will appear at the top of the email, above the product table.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)] rounded-b-2xl">
|
||||
<div className="text-sm text-[var(--admin-text-muted)]">
|
||||
{customerCount === 1 ? "1 customer" : `${customerCount} customers`} will receive this email.
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<AdminButton type="button" variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</AdminButton>
|
||||
<AdminButton type="submit" variant="primary" disabled={sending || !subject.trim()} isLoading={sending}>
|
||||
{sending ? "Sending..." : `Send to ${customerCount === 1 ? "1 Customer" : `${customerCount} Customers`}`}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useEffect } from "react";
|
||||
import {
|
||||
type WholesaleProduct,
|
||||
deleteWholesaleProduct,
|
||||
saveWholesaleProduct,
|
||||
} from "@/actions/wholesale";
|
||||
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
|
||||
|
||||
type ProductForm = {
|
||||
name: string;
|
||||
description: string;
|
||||
unitType: string;
|
||||
availability: string;
|
||||
qtyAvailable: number;
|
||||
priceTiers: string;
|
||||
hpSku: string;
|
||||
hpItemId: string;
|
||||
handlingInstructions: string;
|
||||
storageWarning: string;
|
||||
productLabel: string;
|
||||
};
|
||||
|
||||
const EMPTY_PRODUCT_FORM: ProductForm = {
|
||||
name: "",
|
||||
description: "",
|
||||
unitType: "each",
|
||||
availability: "available",
|
||||
qtyAvailable: 0,
|
||||
priceTiers: "",
|
||||
hpSku: "",
|
||||
hpItemId: "",
|
||||
handlingInstructions: "",
|
||||
storageWarning: "",
|
||||
productLabel: "",
|
||||
};
|
||||
|
||||
function productFormFromProduct(p: WholesaleProduct): ProductForm {
|
||||
return {
|
||||
name: p.name,
|
||||
description: p.description ?? "",
|
||||
unitType: p.unit_type,
|
||||
availability: p.availability,
|
||||
qtyAvailable: Number(p.qty_available),
|
||||
priceTiers: JSON.stringify(p.price_tiers),
|
||||
hpSku: p.hp_sku ?? "",
|
||||
hpItemId: p.hp_item_id ?? "",
|
||||
handlingInstructions: p.handling_instructions ?? "",
|
||||
storageWarning: p.storage_warning ?? "",
|
||||
productLabel: p.product_label ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
type State = {
|
||||
showForm: boolean;
|
||||
editing: WholesaleProduct | null;
|
||||
form: ProductForm;
|
||||
saving: boolean;
|
||||
openProductActions: string | null;
|
||||
deletingProduct: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "open_new" }
|
||||
| { type: "open_edit"; product: WholesaleProduct }
|
||||
| { type: "set_show_form"; showForm: boolean }
|
||||
| { type: "set_form_field"; field: keyof ProductForm; value: ProductForm[keyof ProductForm] }
|
||||
| { type: "set_saving"; saving: boolean }
|
||||
| { type: "set_open_product_actions"; id: string | null }
|
||||
| { type: "set_deleting_product"; id: string | null };
|
||||
|
||||
const initialState: State = {
|
||||
showForm: false,
|
||||
editing: null,
|
||||
form: EMPTY_PRODUCT_FORM,
|
||||
saving: false,
|
||||
openProductActions: null,
|
||||
deletingProduct: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "open_new":
|
||||
return { ...state, editing: null, form: EMPTY_PRODUCT_FORM, showForm: true };
|
||||
case "open_edit":
|
||||
return { ...state, editing: action.product, form: productFormFromProduct(action.product), showForm: true };
|
||||
case "set_show_form":
|
||||
return { ...state, showForm: action.showForm };
|
||||
case "set_form_field":
|
||||
return { ...state, form: { ...state.form, [action.field]: action.value } };
|
||||
case "set_saving":
|
||||
return { ...state, saving: action.saving };
|
||||
case "set_open_product_actions":
|
||||
return { ...state, openProductActions: action.id };
|
||||
case "set_deleting_product":
|
||||
return { ...state, deletingProduct: action.id };
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProductsTab({ products, brandId, onMsg, onRefresh }: {
|
||||
products: WholesaleProduct[];
|
||||
brandId: string;
|
||||
onMsg: (kind: "success" | "error", text: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
// Close product actions dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!(e.target as Element).closest(".product-actions-cell")) {
|
||||
dispatch({ type: "set_open_product_actions", id: null });
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
async function handleDeleteProduct(productId: string) {
|
||||
if (!confirm("Delete this product? Products attached to order items cannot be deleted. Set availability to unavailable instead.")) return;
|
||||
dispatch({ type: "set_deleting_product", id: productId });
|
||||
const result = await deleteWholesaleProduct(productId);
|
||||
dispatch({ type: "set_deleting_product", id: null });
|
||||
if (result.success) {
|
||||
onMsg("success", "Product deleted.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to delete product.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
dispatch({ type: "set_saving", saving: true });
|
||||
let tiers: Array<{ min_qty: number; max_qty: number; price: number }> = [];
|
||||
try { tiers = JSON.parse(state.form.priceTiers || "[]"); } catch {}
|
||||
const result = await saveWholesaleProduct({
|
||||
brandId,
|
||||
id: state.editing?.id,
|
||||
name: state.form.name,
|
||||
description: state.form.description || undefined,
|
||||
unitType: state.form.unitType,
|
||||
availability: state.form.availability,
|
||||
qtyAvailable: state.form.qtyAvailable,
|
||||
priceTiers: tiers,
|
||||
hpSku: state.form.hpSku || undefined,
|
||||
hpItemId: state.form.hpItemId || undefined,
|
||||
handlingInstructions: state.form.handlingInstructions || undefined,
|
||||
storageWarning: state.form.storageWarning || undefined,
|
||||
productLabel: state.form.productLabel || undefined,
|
||||
});
|
||||
dispatch({ type: "set_saving", saving: false });
|
||||
if (result.success) {
|
||||
onMsg("success", state.editing ? "Product updated." : "Product created.");
|
||||
dispatch({ type: "set_show_form", showForm: false });
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to save.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Products ({products.length})</h2>
|
||||
<AdminButton variant="primary" size="sm" onClick={() => dispatch({ type: "open_new" })}>
|
||||
+ Add Product
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
{state.showForm && (
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{state.editing ? "Edit Product" : "New Product"}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="ws-prod-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label>
|
||||
<input aria-label="Ws Prod Name" id="ws-prod-name" value={state.form.name} onChange={e => dispatch({ type: "set_form_field", field: "name", value: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="ws-prod-desc" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label>
|
||||
<textarea aria-label="Ws Prod Desc" id="ws-prod-desc" value={state.form.description} onChange={e => dispatch({ type: "set_form_field", field: "description", value: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ws-prod-unit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label>
|
||||
<select aria-label="Ws Prod Unit" id="ws-prod-unit" value={state.form.unitType} onChange={e => dispatch({ type: "set_form_field", field: "unitType", value: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
|
||||
{["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => (
|
||||
<option key={u} value={u}>{u}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-1-availability" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Availability</label>
|
||||
<select id="fld-1-availability" aria-label="Select" value={state.form.availability} onChange={e => dispatch({ type: "set_form_field", field: "availability", value: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="available">Available</option>
|
||||
<option value="unavailable">Unavailable</option>
|
||||
<option value="coming_soon">Coming Soon</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-2-qty-available" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Qty Available</label>
|
||||
<input id="fld-2-qty-available" aria-label="Number" type="number" value={state.form.qtyAvailable} onChange={e => dispatch({ type: "set_form_field", field: "qtyAvailable", value: Number(e.target.value) })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-3-hp-sku" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">HP SKU</label>
|
||||
<input id="fld-3-hp-sku" aria-label="Input" value={state.form.hpSku} onChange={e => dispatch({ type: "set_form_field", field: "hpSku", value: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="fld-4-price-tiers-json-array-of-min-qty1max-qty24price20" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Price Tiers (JSON array of {"{\"min_qty\":1,\"max_qty\":24,\"price\":20}"})</label>
|
||||
<input id="fld-4-price-tiers-json-array-of-min-qty1max-qty24price20" aria-label="[{"min Qty":1,"max Qty":24,"price":20},{"min Qty":25,"max Qty":0,"price":18}]" value={state.form.priceTiers} onChange={e => dispatch({ type: "set_form_field", field: "priceTiers", value: e.target.value })}
|
||||
placeholder='[{"min_qty":1,"max_qty":24,"price":20},{"min_qty":25,"max_qty":0,"price":18}]'
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm font-mono outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<AdminButton onClick={handleSave} disabled={state.saving} variant="primary" isLoading={state.saving}>
|
||||
{state.saving ? "Saving..." : "Save Product"}
|
||||
</AdminButton>
|
||||
<AdminButton onClick={() => dispatch({ type: "set_show_form", showForm: false })} variant="secondary">
|
||||
Cancel
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-semibold text-left">Product</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Unit</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Availability</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">In Stock</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">HP SKU</th>
|
||||
<th className="px-5 py-3" aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{products.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-600 mb-1">No wholesale products yet</p>
|
||||
<p className="text-xs text-slate-400">Add products to your wholesale catalog to get started.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : products.map(p => (
|
||||
<tr key={p.id} className="hover:bg-[var(--admin-bg-subtle)]">
|
||||
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.unit_type}</td>
|
||||
<td className="px-5 py-3">
|
||||
<AdminBadge variant={p.availability === "available" ? "success" : p.availability === "coming_soon" ? "warning" : "default"}>
|
||||
{p.availability.replace("_", " ")}
|
||||
</AdminBadge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.qty_available}</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-[var(--admin-text-muted)]">{p.hp_sku ?? "—"}</td>
|
||||
<td className="px-5 py-4 product-actions-cell relative">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<button type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dispatch({ type: "set_open_product_actions", id: state.openProductActions === p.id ? null : p.id });
|
||||
}}
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
|
||||
</button>
|
||||
{state.openProductActions === p.id && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-1 z-30 w-48 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={() => { dispatch({ type: "set_open_product_actions", id: null }); dispatch({ type: "open_edit", product: p }); }}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Edit Product
|
||||
</button>
|
||||
<div className="my-1 border-t border-[var(--admin-border)]" />
|
||||
<button type="button"
|
||||
onClick={() => { dispatch({ type: "set_open_product_actions", id: null }); handleDeleteProduct(p.id); }}
|
||||
disabled={state.deletingProduct === p.id}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
{state.deletingProduct === p.id ? "Deleting..." : "Delete Product"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type WholesaleSettings,
|
||||
type NotificationRecipient,
|
||||
saveWholesaleSettings,
|
||||
} from "@/actions/wholesale";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
import AddRecipientForm from "./AddRecipientForm";
|
||||
import WebhookSettingsSection from "./WebhookSettingsSection";
|
||||
|
||||
type SettingsForm = {
|
||||
requireApproval: boolean;
|
||||
minOrderAmount: string;
|
||||
onlinePaymentEnabled: boolean;
|
||||
wholesaleEnabled: boolean;
|
||||
squareSyncEnabled: boolean;
|
||||
pickupLocation: string;
|
||||
fobLocation: string;
|
||||
fromEmail: string;
|
||||
invoiceBusinessName: string;
|
||||
invoiceBusinessAddress: string;
|
||||
invoiceBusinessPhone: string;
|
||||
invoiceBusinessEmail: string;
|
||||
invoiceBusinessWebsite: string;
|
||||
notificationRecipients: NotificationRecipient[];
|
||||
};
|
||||
|
||||
function settingsFormFromSettings(settings: WholesaleSettings | null): SettingsForm {
|
||||
return {
|
||||
requireApproval: settings?.require_approval ?? true,
|
||||
minOrderAmount: settings?.min_order_amount != null ? String(settings.min_order_amount) : "",
|
||||
onlinePaymentEnabled: settings?.online_payment_enabled ?? false,
|
||||
wholesaleEnabled: settings?.wholesale_enabled ?? true,
|
||||
squareSyncEnabled: settings?.square_sync_enabled ?? false,
|
||||
pickupLocation: settings?.pickup_location ?? "",
|
||||
fobLocation: settings?.fob_location ?? "",
|
||||
fromEmail: settings?.from_email ?? "",
|
||||
invoiceBusinessName: settings?.invoice_business_name ?? "",
|
||||
invoiceBusinessAddress: settings?.invoice_business_address ?? "",
|
||||
invoiceBusinessPhone: settings?.invoice_business_phone ?? "",
|
||||
invoiceBusinessEmail: settings?.invoice_business_email ?? "",
|
||||
invoiceBusinessWebsite: settings?.invoice_business_website ?? "",
|
||||
notificationRecipients: settings?.notification_recipients ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }: {
|
||||
settings: WholesaleSettings | null;
|
||||
brandId: string;
|
||||
onMsg: (kind: "success" | "error", text: string) => void;
|
||||
onRefresh: () => void;
|
||||
canManageSettings: boolean;
|
||||
}) {
|
||||
// Hooks must be called unconditionally - always declare them first
|
||||
const [form, setForm] = useState<SettingsForm>(() => settingsFormFromSettings(settings));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Permission check after hooks
|
||||
if (!canManageSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-slate-400">You do not have permission to manage settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
const result = await saveWholesaleSettings({
|
||||
brandId,
|
||||
requireApproval: form.requireApproval,
|
||||
minOrderAmount: form.minOrderAmount ? Number(form.minOrderAmount) : undefined,
|
||||
onlinePaymentEnabled: form.onlinePaymentEnabled,
|
||||
wholesaleEnabled: form.wholesaleEnabled,
|
||||
squareSyncEnabled: form.squareSyncEnabled,
|
||||
pickupLocation: form.pickupLocation,
|
||||
fobLocation: form.fobLocation,
|
||||
fromEmail: form.fromEmail,
|
||||
invoiceBusinessName: form.invoiceBusinessName,
|
||||
invoiceBusinessAddress: form.invoiceBusinessAddress,
|
||||
invoiceBusinessPhone: form.invoiceBusinessPhone,
|
||||
invoiceBusinessWebsite: form.invoiceBusinessWebsite,
|
||||
notificationRecipients: form.notificationRecipients,
|
||||
});
|
||||
setSaving(false);
|
||||
if (result.success) {
|
||||
onMsg("success", "Settings saved.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to save settings.");
|
||||
}
|
||||
}
|
||||
|
||||
function setField<K extends keyof SettingsForm>(field: K, value: SettingsForm[K]) {
|
||||
setForm(f => ({ ...f, [field]: value }));
|
||||
}
|
||||
|
||||
// ── Notification Recipients helpers ────────────────────────────────────────
|
||||
function addRecipient(email: string, name: string = "") {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
notificationRecipients: [
|
||||
...f.notificationRecipients,
|
||||
{ email, name, active: true },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function removeRecipient(idx: number) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
notificationRecipients: f.notificationRecipients.filter((_: unknown, i: number) => i !== idx),
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleRecipient(idx: number) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
|
||||
i === idx ? { ...r, active: !r.active } : r
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function updateRecipientName(idx: number, name: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
|
||||
i === idx ? { ...r, name } : r
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Settings</h2>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-[var(--admin-text-primary)]">Require Approval</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">New registrations must be manually approved.</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setField("requireApproval", !form.requireApproval)}
|
||||
aria-label="Toggle require approval"
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.requireApproval ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
|
||||
>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.requireApproval ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-[var(--admin-text-primary)]">Wholesale Portal</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand's storefront page.</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setField("wholesaleEnabled", !form.wholesaleEnabled)}
|
||||
aria-label="Toggle wholesale portal"
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.wholesaleEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
|
||||
>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.wholesaleEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent-light)]">
|
||||
<svg className="h-4 w-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--admin-text-primary)]">Square Sync for Wholesale</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
When enabled, wholesale product changes are automatically pushed to Square.
|
||||
{form.squareSyncEnabled
|
||||
? " Auto-sync is active."
|
||||
: " Auto-sync is disabled — changes will not be pushed to Square."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setField("squareSyncEnabled", !form.squareSyncEnabled)}
|
||||
aria-label="Toggle square sync"
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.squareSyncEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
|
||||
>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.squareSyncEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-12-minimum-order-amount" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Minimum Order Amount ($)</label>
|
||||
<input id="fld-12-minimum-order-amount" aria-label="No Minimum" type="number" value={form.minOrderAmount} onChange={e => setField("minOrderAmount", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" placeholder="No minimum" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-13-from-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">From Email</label>
|
||||
<input id="fld-13-from-email" aria-label="Email" type="email" value={form.fromEmail} onChange={e => setField("fromEmail", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="fld-14-pickup-location" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Pickup Location</label>
|
||||
<textarea id="fld-14-pickup-location" aria-label="Textarea" value={form.pickupLocation} onChange={e => setField("pickupLocation", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] font-mono" rows={3} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="fld-15-fob-location" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">FOB Location</label>
|
||||
<input id="fld-15-fob-location" aria-label="Input" value={form.fobLocation} onChange={e => setField("fobLocation", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="fld-16-invoice-business-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Name</label>
|
||||
<input id="fld-16-invoice-business-name" aria-label="Input" value={form.invoiceBusinessName} onChange={e => setField("invoiceBusinessName", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="fld-17-invoice-business-address" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Address</label>
|
||||
<textarea id="fld-17-invoice-business-address" aria-label="Textarea" value={form.invoiceBusinessAddress} onChange={e => setField("invoiceBusinessAddress", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-18-invoice-business-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Phone</label>
|
||||
<input id="fld-18-invoice-business-phone" aria-label="Input" value={form.invoiceBusinessPhone} onChange={e => setField("invoiceBusinessPhone", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-19-invoice-business-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Email</label>
|
||||
<input id="fld-19-invoice-business-email" aria-label="Email" type="email" value={form.invoiceBusinessEmail} onChange={e => setField("invoiceBusinessEmail", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="fld-20-invoice-business-website" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Website</label>
|
||||
<input id="fld-20-invoice-business-website" aria-label="Https://" value={form.invoiceBusinessWebsite} onChange={e => setField("invoiceBusinessWebsite", e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="https://" />
|
||||
</div>
|
||||
|
||||
{/* Team Notification Recipients */}
|
||||
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1" htmlFor="fld-team-notification-recipients">Team Notification Recipients</label>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
|
||||
These team members receive all wholesale notifications for this brand — new orders,
|
||||
deposits, fulfillments, price sheets, and pickup reminders. If no recipients are
|
||||
active, the system falls back to any configured notification email on file.
|
||||
</p>
|
||||
|
||||
{/* Empty state */}
|
||||
{form.notificationRecipients.length === 0 && (
|
||||
<div className="text-center py-6 mb-4 rounded-xl bg-white ring-1 ring-[var(--admin-border)]">
|
||||
<p className="text-sm text-[var(--admin-text-muted)] mb-1">No recipients added yet.</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Add an email address below to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recipients list */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{form.notificationRecipients.map((r: NotificationRecipient, idx: number) => (
|
||||
<div key={r.email || `recipient-${idx}`} className={`flex items-center gap-2 rounded-xl px-3 py-2.5 bg-white ring-1 ${r.active ? "ring-[var(--admin-border)]" : "ring-[var(--admin-border-light)] opacity-70"}`}>
|
||||
<input id="fld-team-notification-recipients" aria-label="Checkbox"
|
||||
type="checkbox" checked={r.active} onChange={() => toggleRecipient(idx)}
|
||||
className="rounded border-[var(--admin-border)] mt-0.5" title={r.active ? "Active — receives notifications" : "Inactive"} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${r.active ? "text-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"}`}>{r.email}</p>
|
||||
{r.name && <p className="text-xs text-[var(--admin-text-muted)] truncate">{r.name}</p>}
|
||||
{r.notification_types && r.notification_types.length > 0 && (
|
||||
<p className="text-xs text-[var(--admin-text-secondary)] mt-0.5" title="Per-type filtering coming soon">Types: {r.notification_types.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
<input aria-label="Name (optional)"
|
||||
type="text" placeholder="Name (optional)"
|
||||
value={r.name ?? ""}
|
||||
onChange={e => updateRecipientName(idx, e.target.value)}
|
||||
className="rounded-xl border border-[var(--admin-border)] px-2.5 py-1.5 text-xs w-36 outline-none focus:border-[var(--admin-accent)]" />
|
||||
<button type="button" onClick={() => removeRecipient(idx)}
|
||||
aria-label="Remove recipient"
|
||||
className="text-[var(--admin-danger)] hover:text-[var(--admin-danger)] text-xs font-medium px-2 py-1 rounded-lg hover:bg-[var(--admin-danger-light)] transition-colors"
|
||||
title="Remove recipient">
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add recipient */}
|
||||
<AddRecipientForm onAdd={addRecipient} />
|
||||
</div>
|
||||
{/* Webhook Settings */}
|
||||
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1" htmlFor="fld-webhook-integration">Webhook Integration</label>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
|
||||
Send order events to external systems (Harvest Point, ERPs, etc.). Payload is signed
|
||||
with HMAC-SHA256. Enable and configure the URL and secret below.
|
||||
</p>
|
||||
<WebhookSettingsSection brandId={brandId} onMsg={onMsg} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<AdminButton onClick={handleSave} disabled={saving} variant="primary" isLoading={saving}>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { AdminBadge } from "@/components/admin/design-system";
|
||||
|
||||
const STATUS_BADGE_MAP: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
|
||||
pending: "warning",
|
||||
awaiting_deposit: "info",
|
||||
confirmed: "info",
|
||||
fulfilled: "success",
|
||||
};
|
||||
|
||||
const STATUS_BADGE_LABEL: Record<string, string> = {
|
||||
pending: "Pending",
|
||||
awaiting_deposit: "Awaiting Deposit",
|
||||
confirmed: "Confirmed",
|
||||
fulfilled: "Fulfilled",
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<AdminBadge variant={STATUS_BADGE_MAP[status] ?? "default"}>
|
||||
{STATUS_BADGE_LABEL[status] ?? status}
|
||||
</AdminBadge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import crypto from "crypto";
|
||||
import { getWebhookSettings, saveWebhookSettings } from "@/actions/wholesale";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
type WebhookSettings = { url: string; secret: string; enabled: boolean };
|
||||
|
||||
export default function WebhookSettingsSection({ brandId, onMsg }: {
|
||||
brandId: string;
|
||||
onMsg: (kind: "success" | "error", text: string) => void;
|
||||
}) {
|
||||
const [settings, setSettings] = useState<WebhookSettings>({ url: "", secret: "", enabled: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getWebhookSettings(brandId).then((s: WebhookSettings | null) => {
|
||||
if (s) {
|
||||
setSettings({ url: s.url ?? "", secret: s.secret ?? "", enabled: s.enabled });
|
||||
} else {
|
||||
setSettings({ url: "", secret: "", enabled: false });
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, [brandId]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
const result = await saveWebhookSettings({
|
||||
brandId,
|
||||
url: settings.url,
|
||||
secret: settings.secret,
|
||||
enabled: settings.enabled,
|
||||
});
|
||||
setSaving(false);
|
||||
if (result.success) {
|
||||
onMsg("success", "Webhook settings saved.");
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to save webhook settings.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestDispatch() {
|
||||
if (!settings.url || !settings.secret) {
|
||||
onMsg("error", "Enter both a webhook URL and signing secret before testing.");
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
onMsg("success", "Sending test webhook...");
|
||||
|
||||
const testPayload = {
|
||||
event: "order_created",
|
||||
test: true,
|
||||
brand_id: brandId,
|
||||
message: "Test webhook from Route Commerce wholesale portal",
|
||||
timestamp: new Date().toISOString(),
|
||||
order_id: null,
|
||||
};
|
||||
const payloadString = JSON.stringify(testPayload);
|
||||
const signature = crypto
|
||||
.createHmac("sha256", settings.secret)
|
||||
.update(payloadString)
|
||||
.digest("hex");
|
||||
|
||||
try {
|
||||
const res = await fetch(settings.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Signature": `sha256=${signature}`,
|
||||
"X-Webhook-Event": "order_created",
|
||||
},
|
||||
body: payloadString,
|
||||
});
|
||||
const responseText = await res.text().catch(() => "");
|
||||
if (res.ok) {
|
||||
onMsg("success", `Test delivered — ${res.status} response from your endpoint. Check wholesale_sync_log for the logged entry.`);
|
||||
} else {
|
||||
onMsg("error", `Webhook endpoint returned ${res.status}: ${responseText.slice(0, 120)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
onMsg("error", `Connection failed: ${err instanceof Error ? err.message : "Network error"}`);
|
||||
}
|
||||
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-slate-400">Loading webhook settings...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Webhook Enabled</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">When disabled, no events are queued or sent.</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setSettings(s => ({ ...s, enabled: !s.enabled }))}
|
||||
aria-label="Toggle webhook"
|
||||
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${settings.enabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
|
||||
>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${settings.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-21-webhook-url" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Webhook URL</label>
|
||||
<input id="fld-21-webhook-url" aria-label="Https://your System.com/webhooks/wholesale"
|
||||
type="url"
|
||||
value={settings.url}
|
||||
onChange={e => setSettings(s => ({ ...s, url: e.target.value }))}
|
||||
placeholder="https://your-system.com/webhooks/wholesale"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-22-signing-secret-hmac-sha256" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Signing Secret (HMAC-SHA256)</label>
|
||||
<input id="fld-22-signing-secret-hmac-sha256" aria-label="Leave Blank To Keep Current Secret"
|
||||
type="password"
|
||||
value={settings.secret}
|
||||
onChange={e => setSettings(s => ({ ...s, secret: e.target.value }))}
|
||||
placeholder="Leave blank to keep current secret"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] font-mono"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
The receiver should verify the <code className="bg-[var(--admin-bg-subtle)] px-1">X-Webhook-Signature</code> header using this secret.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<AdminButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Webhook Settings"}
|
||||
</AdminButton>
|
||||
{settings.enabled && settings.url && (
|
||||
<AdminButton
|
||||
onClick={handleTestDispatch}
|
||||
disabled={testing}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={testing}
|
||||
>
|
||||
{testing ? "Dispatching..." : "Send Test Webhook"}
|
||||
</AdminButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type WholesaleCustomer,
|
||||
} from "@/actions/wholesale";
|
||||
|
||||
export type CustomerForm = {
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
accountStatus: string;
|
||||
creditLimit: number;
|
||||
depositsEnabled: boolean;
|
||||
depositThreshold: string;
|
||||
depositPercentage: string;
|
||||
orderEmail: string;
|
||||
invoiceEmail: string;
|
||||
adminNotes: string;
|
||||
};
|
||||
|
||||
export const EMPTY_CUSTOMER_FORM: CustomerForm = {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
accountStatus: "active",
|
||||
creditLimit: 0,
|
||||
depositsEnabled: false,
|
||||
depositThreshold: "",
|
||||
depositPercentage: "",
|
||||
orderEmail: "",
|
||||
invoiceEmail: "",
|
||||
adminNotes: "",
|
||||
};
|
||||
|
||||
export function customerFormFromCustomer(c: WholesaleCustomer): CustomerForm {
|
||||
return {
|
||||
companyName: c.company_name ?? "",
|
||||
contactName: c.contact_name ?? "",
|
||||
email: c.email ?? "",
|
||||
phone: c.phone ?? "",
|
||||
accountStatus: c.account_status ?? "active",
|
||||
creditLimit: Number(c.credit_limit),
|
||||
depositsEnabled: c.deposits_enabled,
|
||||
depositThreshold: c.deposit_threshold?.toString() ?? "",
|
||||
depositPercentage: c.deposit_percentage?.toString() ?? "",
|
||||
orderEmail: c.order_email ?? "",
|
||||
invoiceEmail: c.invoice_email ?? "",
|
||||
adminNotes: c.admin_notes ?? "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type WholesaleOrder,
|
||||
type WholesaleCustomer,
|
||||
enqueueWholesaleNotification,
|
||||
getWholesaleSettings,
|
||||
markWholesaleOrderFulfilled,
|
||||
} from "@/actions/wholesale";
|
||||
|
||||
export async function fulfillWithNotifications(
|
||||
orderId: string,
|
||||
orders: WholesaleOrder[],
|
||||
customers: WholesaleCustomer[],
|
||||
brandId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await markWholesaleOrderFulfilled(orderId);
|
||||
if (!result.success) return result;
|
||||
|
||||
const order = orders.find(o => o.id === orderId);
|
||||
const customer = order ? customers.find(c => c.email === order.customer_email) : null;
|
||||
if (order && customer) {
|
||||
enqueueWholesaleNotification({
|
||||
brandId: brandId,
|
||||
customerId: customer.id,
|
||||
orderId,
|
||||
type: "order_fulfilled",
|
||||
emailTo: customer.email,
|
||||
subject: `Order ${order.invoice_number ?? orderId.slice(0, 8)} Ready for Pickup`,
|
||||
bodyHtml: `
|
||||
<h2>Your Order is Ready!</h2>
|
||||
<p>Hi ${customer.company_name},</p>
|
||||
<p>Your wholesale order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}.</p>
|
||||
<p>Thank you for your business!</p>
|
||||
`,
|
||||
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}. Thank you!`,
|
||||
});
|
||||
|
||||
const settings = await getWholesaleSettings(brandId);
|
||||
const adminEmail = settings?.notification_email ?? settings?.from_email ?? settings?.invoice_business_email ?? null;
|
||||
if (adminEmail) {
|
||||
enqueueWholesaleNotification({
|
||||
brandId: brandId,
|
||||
customerId: customer.id,
|
||||
orderId,
|
||||
type: "order_fulfilled",
|
||||
emailTo: adminEmail,
|
||||
subject: `[Admin] Order Fulfilled — ${order.invoice_number ?? orderId.slice(0, 8)}`,
|
||||
bodyHtml: `
|
||||
<h2>Order Fulfilled</h2>
|
||||
<p>Order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> for <strong>${customer.company_name}</strong> has been marked as fulfilled.</p>
|
||||
${order.anticipated_pickup_date ? `<p><strong>Pickup Date:</strong> ${order.anticipated_pickup_date}</p>` : ""}
|
||||
`,
|
||||
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} for ${customer.company_name} has been fulfilled.`,
|
||||
});
|
||||
}
|
||||
|
||||
fetch(`/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function generateManifestHtml(orders: WholesaleOrder[], brandId: string): Promise<string> {
|
||||
return fetch("/api/wholesale/manifest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, orders }),
|
||||
}).then(r => r.text());
|
||||
}
|
||||
|
||||
export function openManifestWindow(orders: WholesaleOrder[], brandId: string): void {
|
||||
generateManifestHtml(orders, brandId).then(html => {
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, "_blank");
|
||||
if (w) {
|
||||
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} else {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
+566
-354
@@ -1,14 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useReducer, useEffect, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import { getPublicStopsForBrand, checkStopProductAvailability, type PublicStop } from "@/actions/checkout";
|
||||
import {
|
||||
getPublicStopsForBrand,
|
||||
checkStopProductAvailability,
|
||||
type PublicStop,
|
||||
} from "@/actions/checkout";
|
||||
|
||||
type Stop = PublicStop;
|
||||
|
||||
type State = {
|
||||
stops: Stop[];
|
||||
loadingStops: boolean;
|
||||
showStopPicker: boolean;
|
||||
incompatibleItems: string[];
|
||||
availabilityError: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "OPEN_PICKER" }
|
||||
| { type: "CLOSE_PICKER" }
|
||||
| { type: "SET_STOPS_LOADING"; value: boolean }
|
||||
| { type: "SET_STOPS"; stops: Stop[] }
|
||||
| { type: "RESET_PICKER_AND_AVAILABILITY" }
|
||||
| { type: "SET_INCOMPATIBLE"; items: string[] }
|
||||
| { type: "SET_AVAILABILITY_ERROR"; value: boolean };
|
||||
|
||||
const initialState: State = {
|
||||
stops: [],
|
||||
loadingStops: false,
|
||||
showStopPicker: false,
|
||||
incompatibleItems: [],
|
||||
availabilityError: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "OPEN_PICKER":
|
||||
return { ...state, showStopPicker: true };
|
||||
case "CLOSE_PICKER":
|
||||
return { ...state, showStopPicker: false };
|
||||
case "SET_STOPS_LOADING":
|
||||
return { ...state, loadingStops: action.value };
|
||||
case "SET_STOPS":
|
||||
return { ...state, stops: action.stops, loadingStops: false };
|
||||
case "RESET_PICKER_AND_AVAILABILITY":
|
||||
return { ...state, showStopPicker: false, availabilityError: false, incompatibleItems: [] };
|
||||
case "SET_INCOMPATIBLE":
|
||||
return { ...state, incompatibleItems: action.items };
|
||||
case "SET_AVAILABILITY_ERROR":
|
||||
return { ...state, availabilityError: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function CartClient() {
|
||||
const {
|
||||
cart,
|
||||
@@ -21,98 +69,104 @@ export default function CartClient() {
|
||||
removeFromCart,
|
||||
} = useCart();
|
||||
|
||||
const [stops, setStops] = useState<Stop[]>([]);
|
||||
const [loadingStops, setLoadingStops] = useState(false);
|
||||
const [showStopPicker, setShowStopPicker] = useState(false);
|
||||
const [incompatibleItems, setIncompatibleItems] = useState<string[]>([]);
|
||||
const [availabilityError, setAvailabilityError] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
|
||||
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
|
||||
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed");
|
||||
const hasShedPickupItems = cart.some(
|
||||
(i) => i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
);
|
||||
const hasStopPickupItems = cart.some(
|
||||
(i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"
|
||||
);
|
||||
|
||||
// Brand mismatch is a pure derivation of selectedStop + cartBrandId —
|
||||
// compute it inline during render instead of mirroring it into state
|
||||
// and syncing via useEffect. This also removes the "fake event handler"
|
||||
// anti-pattern flagged by react-doctor.
|
||||
// and syncing via useEffect.
|
||||
const stopBrandMismatch = !!(
|
||||
selectedStop?.brand_id &&
|
||||
cartBrandId &&
|
||||
selectedStop.brand_id !== cartBrandId
|
||||
);
|
||||
|
||||
// Stops are fetched on demand when the picker opens. The handler
|
||||
// below owns the side effect — no useEffect watching showStopPicker.
|
||||
const handleOpenStopPicker = useCallback(() => {
|
||||
setShowStopPicker(true);
|
||||
if (hasPickupItems && cartBrandId && stops.length === 0) {
|
||||
setLoadingStops(true);
|
||||
dispatch({ type: "OPEN_PICKER" });
|
||||
if (hasPickupItems && cartBrandId && state.stops.length === 0) {
|
||||
dispatch({ type: "SET_STOPS_LOADING", value: true });
|
||||
getPublicStopsForBrand(cartBrandId)
|
||||
.then((data) => setStops(data ?? []))
|
||||
.catch(() => setStops([]))
|
||||
.finally(() => setLoadingStops(false));
|
||||
.then((data) => dispatch({ type: "SET_STOPS", stops: data ?? [] }))
|
||||
.catch(() => dispatch({ type: "SET_STOPS", stops: [] }));
|
||||
}
|
||||
}, [hasPickupItems, cartBrandId, stops.length]);
|
||||
}, [hasPickupItems, cartBrandId, state.stops.length]);
|
||||
|
||||
const handleStopSelect = useCallback((stop: Stop) => {
|
||||
setSelectedStop(stop);
|
||||
setShowStopPicker(false);
|
||||
setAvailabilityError(false);
|
||||
setIncompatibleItems([]);
|
||||
const handleStopSelect = useCallback(
|
||||
(stop: Stop) => {
|
||||
setSelectedStop(stop);
|
||||
dispatch({ type: "RESET_PICKER_AND_AVAILABILITY" });
|
||||
|
||||
if (hasPickupItems) {
|
||||
const pickupProductIds: string[] = [];
|
||||
for (const i of cart) {
|
||||
if (i.fulfillment === "pickup") pickupProductIds.push(i.id);
|
||||
if (hasPickupItems) {
|
||||
const pickupProductIds: string[] = [];
|
||||
for (const i of cart) {
|
||||
if (i.fulfillment === "pickup") pickupProductIds.push(i.id);
|
||||
}
|
||||
checkStopProductAvailability(stop.id, pickupProductIds)
|
||||
.then((data) => {
|
||||
const unavailable: string[] = [];
|
||||
for (const row of data ?? []) {
|
||||
if (row.is_available === false) unavailable.push(row.product_id);
|
||||
}
|
||||
dispatch({ type: "SET_INCOMPATIBLE", items: unavailable });
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch({ type: "SET_AVAILABILITY_ERROR", value: true });
|
||||
dispatch({ type: "SET_INCOMPATIBLE", items: [] });
|
||||
});
|
||||
}
|
||||
checkStopProductAvailability(stop.id, pickupProductIds)
|
||||
.then((data) => {
|
||||
const unavailable: string[] = [];
|
||||
for (const row of data ?? []) {
|
||||
if (row.is_available === false) unavailable.push(row.product_id);
|
||||
}
|
||||
setIncompatibleItems(unavailable);
|
||||
})
|
||||
.catch(() => {
|
||||
setAvailabilityError(true);
|
||||
setIncompatibleItems([]);
|
||||
});
|
||||
}
|
||||
}, [cart, hasPickupItems, setSelectedStop]);
|
||||
},
|
||||
[cart, hasPickupItems, setSelectedStop]
|
||||
);
|
||||
|
||||
const handleCheckoutClick = useCallback(() => {
|
||||
if (cart.length === 0) return;
|
||||
if (stopBrandMismatch) { setSelectedStop(null); return; }
|
||||
if (hasStopPickupItems && !selectedStop) { handleOpenStopPicker(); return; }
|
||||
if (incompatibleItems.length > 0) { return; }
|
||||
if (stopBrandMismatch) {
|
||||
setSelectedStop(null);
|
||||
return;
|
||||
}
|
||||
if (hasStopPickupItems && !selectedStop) {
|
||||
handleOpenStopPicker();
|
||||
return;
|
||||
}
|
||||
if (state.incompatibleItems.length > 0) return;
|
||||
window.location.href = "/checkout";
|
||||
}, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]);
|
||||
}, [
|
||||
cart.length,
|
||||
stopBrandMismatch,
|
||||
hasStopPickupItems,
|
||||
selectedStop,
|
||||
state.incompatibleItems,
|
||||
setSelectedStop,
|
||||
handleOpenStopPicker,
|
||||
]);
|
||||
|
||||
const stopPickerDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStopPicker) return;
|
||||
if (!state.showStopPicker) return;
|
||||
const dialog = stopPickerDialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setShowStopPicker(false);
|
||||
dispatch({ type: "CLOSE_PICKER" });
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [showStopPicker]);
|
||||
}, [state.showStopPicker]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" aria-hidden="true" />
|
||||
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<CartBackground />
|
||||
<StorefrontHeader brandName="Your Cart" brandSlug="tuxedo" />
|
||||
|
||||
<main className="px-6 py-12 relative">
|
||||
@@ -121,312 +175,470 @@ export default function CartClient() {
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-white">Shopping Cart</h1>
|
||||
<p className="mt-3 text-zinc-400">Review your products before checkout.</p>
|
||||
|
||||
{/* Brand mismatch alert */}
|
||||
{stopBrandMismatch && (
|
||||
<div className="mt-5 glass-card p-5 border border-red-500/20" role="alert">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">Pickup stop is from a different store</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">Your cart was updated. Please select a new pickup stop.</p>
|
||||
<button type="button"
|
||||
onClick={() => { setSelectedStop(null); handleOpenStopPicker(); }}
|
||||
className="mt-3 rounded-xl bg-red-500 hover:bg-red-400 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-red-500/20"
|
||||
>
|
||||
Choose Correct Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HeaderAlerts
|
||||
stopBrandMismatch={stopBrandMismatch}
|
||||
hasShedPickupItems={hasShedPickupItems}
|
||||
hasStopPickupItems={hasStopPickupItems}
|
||||
selectedStop={selectedStop}
|
||||
cartBrandId={cartBrandId}
|
||||
incompatibleItems={state.incompatibleItems}
|
||||
availabilityError={state.availabilityError}
|
||||
cart={cart}
|
||||
onChooseStop={handleOpenStopPicker}
|
||||
onChooseCorrectStop={() => {
|
||||
setSelectedStop(null);
|
||||
handleOpenStopPicker();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Shed pickup info */}
|
||||
{hasShedPickupItems && (
|
||||
<div className="mt-5 glass-card p-5 border border-emerald-500/20">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">Shed Pickup</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{cart.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<StopPickerDialog
|
||||
dialogRef={stopPickerDialogRef}
|
||||
open={state.showStopPicker}
|
||||
loadingStops={state.loadingStops}
|
||||
stops={state.stops}
|
||||
onSelect={handleStopSelect}
|
||||
onClose={() => dispatch({ type: "CLOSE_PICKER" })}
|
||||
/>
|
||||
|
||||
{/* Stop picker prompt */}
|
||||
{hasStopPickupItems && !selectedStop && !stopBrandMismatch && cartBrandId && (
|
||||
<div className="mt-5 glass-card p-5 border border-amber-500/20">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">Pickup stop needed</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">Your cart has pickup items. Select a stop before checkout.</p>
|
||||
<button type="button"
|
||||
onClick={handleOpenStopPicker}
|
||||
className="mt-3 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-amber-500/20"
|
||||
aria-label="Choose pickup stop"
|
||||
>
|
||||
Choose Pickup Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incompatible items warning */}
|
||||
{incompatibleItems.length > 0 && (
|
||||
<div className="mt-4 glass-card p-5 border border-red-500/20" role="alert">
|
||||
<p className="font-semibold text-white">Some items are not available at this stop:</p>
|
||||
<ul className="mt-2 space-y-1" aria-label="Unavailable items">
|
||||
{incompatibleItems.map((id) => {
|
||||
const item = cart.find((i) => i.id === id);
|
||||
return <li key={id} className="text-sm text-zinc-400">• {item?.name}</li>;
|
||||
})}
|
||||
</ul>
|
||||
<p className="mt-2 text-sm text-zinc-500">Please remove these items or choose a different stop.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Availability error */}
|
||||
{availabilityError && (
|
||||
<div className="mt-4 glass-card p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">Could not verify product availability</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">Stop was selected. Checkout will validate product-stop assignment.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stop picker modal */}
|
||||
{showStopPicker && (
|
||||
<dialog
|
||||
ref={stopPickerDialogRef}
|
||||
aria-labelledby="stop-picker-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/60"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
|
||||
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
|
||||
<p className="mt-2 text-sm text-zinc-400">Select a stop for your pickup items.</p>
|
||||
{loadingStops ? (
|
||||
<div className="mt-4 flex items-center justify-center gap-2 py-4" aria-live="polite">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-600 border-t-white" aria-hidden="true" />
|
||||
<span className="text-sm text-zinc-400">Loading stops...</span>
|
||||
</div>
|
||||
) : stops.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-zinc-400 text-center">No stops available.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{stops.map((stop) => (
|
||||
<button type="button"
|
||||
key={stop.id}
|
||||
onClick={() => handleStopSelect(stop)}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left hover:bg-white/10 transition-all text-white"
|
||||
>
|
||||
<p className="font-medium">{stop.city}, {stop.state}</p>
|
||||
<p className="mt-0.5 text-sm text-zinc-400">{stop.date} · {stop.time} · {stop.location}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => setShowStopPicker(false)}
|
||||
className="mt-5 w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
)}
|
||||
|
||||
{/* Cart items */}
|
||||
<div className="mt-10 space-y-4">
|
||||
{cart.length === 0 ? (
|
||||
<div className="glass-card p-8 text-center">
|
||||
<p className="text-zinc-500">Your cart is empty.</p>
|
||||
<Link href="/" className="mt-4 inline-block text-emerald-400 hover:text-emerald-300 transition-colors">← Continue shopping</Link>
|
||||
</div>
|
||||
) : (
|
||||
cart.map((item) => (
|
||||
<article key={item.id} className="glass-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">{item.name}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{item.fulfillment === "pickup" ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
Pickup
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
|
||||
Shipping
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label={`Quantity controls for ${item.name}`}>
|
||||
<button type="button"
|
||||
onClick={() => decreaseQuantity(item.id)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/10 text-white hover:bg-white/20 active:scale-95 transition-all text-xl font-medium"
|
||||
aria-label={`Decrease quantity of ${item.name}`}
|
||||
>−</button>
|
||||
<span className="w-10 text-center font-semibold text-white" aria-label={`Quantity: ${item.quantity}`}>{item.quantity}</span>
|
||||
<button type="button"
|
||||
onClick={() => increaseQuantity(item.id)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 active:scale-95 transition-all text-xl font-medium shadow-lg shadow-emerald-500/20"
|
||||
aria-label={`Increase quantity of ${item.name}`}
|
||||
>+</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
|
||||
<p className="text-sm font-semibold text-white" aria-label={`Item total: $${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}`}>
|
||||
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
<button type="button"
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
className="text-sm text-zinc-500 hover:text-red-400 transition-colors"
|
||||
aria-label={`Remove ${item.name} from cart`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<CartItemsList
|
||||
cart={cart}
|
||||
onDecrease={decreaseQuantity}
|
||||
onIncrease={increaseQuantity}
|
||||
onRemove={removeFromCart}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Order summary sidebar */}
|
||||
<aside aria-label="Order summary">
|
||||
<div className="glass-card p-6 sticky top-6">
|
||||
<h2 className="text-xl font-semibold text-white">Order Summary</h2>
|
||||
|
||||
{hasPickupItems && (
|
||||
<div className="mt-5">
|
||||
{hasShedPickupItems && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
|
||||
<p className="text-sm font-medium text-emerald-400">
|
||||
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
Shed Pickup
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-zinc-400">
|
||||
{cart.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedStop && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
|
||||
<p className="text-sm font-medium text-emerald-400">
|
||||
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
Pickup: {selectedStop.city}, {selectedStop.state}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-zinc-400">{selectedStop.date} · {selectedStop.time}</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-400">{selectedStop.location}</p>
|
||||
<button type="button"
|
||||
onClick={handleOpenStopPicker}
|
||||
className="mt-2.5 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
aria-label="Change pickup stop"
|
||||
>
|
||||
Change stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!selectedStop && hasStopPickupItems && (
|
||||
<button type="button"
|
||||
onClick={handleOpenStopPicker}
|
||||
className="w-full rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-left text-sm text-amber-400 hover:bg-amber-500/20 transition-all"
|
||||
aria-label="Select pickup stop"
|
||||
>
|
||||
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /></svg>
|
||||
No pickup stop selected — click to choose
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
|
||||
<span className="text-sm font-semibold text-white">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="border-t border-white/5 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-white">Total</span>
|
||||
<span className="text-2xl font-bold text-white">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
onClick={handleCheckoutClick}
|
||||
disabled={
|
||||
cart.length === 0 ||
|
||||
(hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0))
|
||||
}
|
||||
className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5"
|
||||
aria-label={
|
||||
cart.length === 0
|
||||
? "Your cart is empty. Add a product to continue."
|
||||
: incompatibleItems.length > 0
|
||||
? "Remove incompatible items first to proceed to checkout"
|
||||
: !selectedStop && hasStopPickupItems
|
||||
? "Select pickup stop first to proceed to checkout"
|
||||
: "Continue to checkout"
|
||||
}
|
||||
>
|
||||
{cart.length === 0
|
||||
? "Cart is Empty"
|
||||
: incompatibleItems.length > 0
|
||||
? "Remove Incompatible Items First"
|
||||
: !selectedStop && hasStopPickupItems
|
||||
? "Select Pickup Stop First"
|
||||
: "Continue to Checkout"}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 block w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Continue shopping"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
<OrderSummary
|
||||
cart={cart}
|
||||
subtotal={subtotal}
|
||||
selectedStop={selectedStop}
|
||||
hasPickupItems={hasPickupItems}
|
||||
hasShedPickupItems={hasShedPickupItems}
|
||||
hasStopPickupItems={hasStopPickupItems}
|
||||
incompatibleItems={state.incompatibleItems}
|
||||
onChangeStop={handleOpenStopPicker}
|
||||
onCheckout={handleCheckoutClick}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartBackground() {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" aria-hidden="true" />
|
||||
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderAlertsProps = {
|
||||
stopBrandMismatch: boolean;
|
||||
hasShedPickupItems: boolean;
|
||||
hasStopPickupItems: boolean;
|
||||
selectedStop: Stop | null;
|
||||
cartBrandId: string | null | undefined;
|
||||
incompatibleItems: string[];
|
||||
availabilityError: boolean;
|
||||
cart: ReturnType<typeof useCart>["cart"];
|
||||
onChooseStop: () => void;
|
||||
onChooseCorrectStop: () => void;
|
||||
};
|
||||
|
||||
function HeaderAlerts({
|
||||
stopBrandMismatch,
|
||||
hasShedPickupItems,
|
||||
hasStopPickupItems,
|
||||
selectedStop,
|
||||
cartBrandId,
|
||||
incompatibleItems,
|
||||
availabilityError,
|
||||
cart,
|
||||
onChooseStop,
|
||||
onChooseCorrectStop,
|
||||
}: HeaderAlertsProps) {
|
||||
return (
|
||||
<>
|
||||
{stopBrandMismatch && (
|
||||
<div className="mt-5 glass-card p-5 border border-red-500/20" role="alert">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">Pickup stop is from a different store</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">Your cart was updated. Please select a new pickup stop.</p>
|
||||
<button type="button"
|
||||
onClick={onChooseCorrectStop}
|
||||
className="mt-3 rounded-xl bg-red-500 hover:bg-red-400 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-red-500/20"
|
||||
>
|
||||
Choose Correct Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasShedPickupItems && (
|
||||
<div className="mt-5 glass-card p-5 border border-emerald-500/20">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">Shed Pickup</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{cart
|
||||
.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasStopPickupItems && !selectedStop && !stopBrandMismatch && cartBrandId && (
|
||||
<div className="mt-5 glass-card p-5 border border-amber-500/20">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">Pickup stop needed</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">Your cart has pickup items. Select a stop before checkout.</p>
|
||||
<button type="button"
|
||||
onClick={onChooseStop}
|
||||
className="mt-3 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-amber-500/20"
|
||||
aria-label="Choose pickup stop"
|
||||
>
|
||||
Choose Pickup Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{incompatibleItems.length > 0 && (
|
||||
<div className="mt-4 glass-card p-5 border border-red-500/20" role="alert">
|
||||
<p className="font-semibold text-white">Some items are not available at this stop:</p>
|
||||
<ul className="mt-2 space-y-1" aria-label="Unavailable items">
|
||||
{incompatibleItems.map((id) => {
|
||||
const item = cart.find((i) => i.id === id);
|
||||
return <li key={id} className="text-sm text-zinc-400">• {item?.name}</li>;
|
||||
})}
|
||||
</ul>
|
||||
<p className="mt-2 text-sm text-zinc-500">Please remove these items or choose a different stop.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availabilityError && (
|
||||
<div className="mt-4 glass-card p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white">Could not verify product availability</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">Stop was selected. Checkout will validate product-stop assignment.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type StopPickerDialogProps = {
|
||||
dialogRef: React.RefObject<HTMLDialogElement | null>;
|
||||
open: boolean;
|
||||
loadingStops: boolean;
|
||||
stops: Stop[];
|
||||
onSelect: (stop: Stop) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function StopPickerDialog({
|
||||
dialogRef,
|
||||
open,
|
||||
loadingStops,
|
||||
stops,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: StopPickerDialogProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-labelledby="stop-picker-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/60"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
|
||||
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
|
||||
<p className="mt-2 text-sm text-zinc-400">Select a stop for your pickup items.</p>
|
||||
{loadingStops ? (
|
||||
<div className="mt-4 flex items-center justify-center gap-2 py-4" aria-live="polite">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-600 border-t-white" aria-hidden="true" />
|
||||
<span className="text-sm text-zinc-400">Loading stops...</span>
|
||||
</div>
|
||||
) : stops.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-zinc-400 text-center">No stops available.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{stops.map((stop) => (
|
||||
<button type="button"
|
||||
key={stop.id}
|
||||
onClick={() => onSelect(stop)}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left hover:bg-white/10 transition-all text-white"
|
||||
>
|
||||
<p className="font-medium">{stop.city}, {stop.state}</p>
|
||||
<p className="mt-0.5 text-sm text-zinc-400">{stop.date} · {stop.time} · {stop.location}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="mt-5 w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type CartItemsListProps = {
|
||||
cart: ReturnType<typeof useCart>["cart"];
|
||||
onDecrease: (id: string) => void;
|
||||
onIncrease: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
};
|
||||
|
||||
function CartItemsList({ cart, onDecrease, onIncrease, onRemove }: CartItemsListProps) {
|
||||
return (
|
||||
<div className="mt-10 space-y-4">
|
||||
{cart.length === 0 ? (
|
||||
<div className="glass-card p-8 text-center">
|
||||
<p className="text-zinc-500">Your cart is empty.</p>
|
||||
<Link href="/" className="mt-4 inline-block text-emerald-400 hover:text-emerald-300 transition-colors">← Continue shopping</Link>
|
||||
</div>
|
||||
) : (
|
||||
cart.map((item) => (
|
||||
<CartItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onDecrease={onDecrease}
|
||||
onIncrease={onIncrease}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CartItemRowProps = {
|
||||
item: ReturnType<typeof useCart>["cart"][number];
|
||||
onDecrease: (id: string) => void;
|
||||
onIncrease: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
};
|
||||
|
||||
function CartItemRow({ item, onDecrease, onIncrease, onRemove }: CartItemRowProps) {
|
||||
const priceNumber = Number(item.price.replace("$", ""));
|
||||
const itemTotal = (priceNumber * item.quantity).toFixed(2);
|
||||
return (
|
||||
<article className="glass-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">{item.name}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{item.fulfillment === "pickup" ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
Pickup
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
|
||||
Shipping
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label={`Quantity controls for ${item.name}`}>
|
||||
<button type="button"
|
||||
onClick={() => onDecrease(item.id)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/10 text-white hover:bg-white/20 active:scale-95 transition-all text-xl font-medium"
|
||||
aria-label={`Decrease quantity of ${item.name}`}
|
||||
>−</button>
|
||||
<span className="w-10 text-center font-semibold text-white" aria-label={`Quantity: ${item.quantity}`}>{item.quantity}</span>
|
||||
<button type="button"
|
||||
onClick={() => onIncrease(item.id)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 active:scale-95 transition-all text-xl font-medium shadow-lg shadow-emerald-500/20"
|
||||
aria-label={`Increase quantity of ${item.name}`}
|
||||
>+</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
|
||||
<p className="text-sm font-semibold text-white" aria-label={`Item total: $${itemTotal}`}>
|
||||
${itemTotal}
|
||||
</p>
|
||||
<button type="button"
|
||||
onClick={() => onRemove(item.id)}
|
||||
className="text-sm text-zinc-500 hover:text-red-400 transition-colors"
|
||||
aria-label={`Remove ${item.name} from cart`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type OrderSummaryProps = {
|
||||
cart: ReturnType<typeof useCart>["cart"];
|
||||
subtotal: number;
|
||||
selectedStop: Stop | null;
|
||||
hasPickupItems: boolean;
|
||||
hasShedPickupItems: boolean;
|
||||
hasStopPickupItems: boolean;
|
||||
incompatibleItems: string[];
|
||||
onChangeStop: () => void;
|
||||
onCheckout: () => void;
|
||||
};
|
||||
|
||||
function OrderSummary({
|
||||
cart,
|
||||
subtotal,
|
||||
selectedStop,
|
||||
hasPickupItems,
|
||||
hasShedPickupItems,
|
||||
hasStopPickupItems,
|
||||
incompatibleItems,
|
||||
onChangeStop,
|
||||
onCheckout,
|
||||
}: OrderSummaryProps) {
|
||||
const checkoutDisabled =
|
||||
cart.length === 0 ||
|
||||
(hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0));
|
||||
return (
|
||||
<aside aria-label="Order summary">
|
||||
<div className="glass-card p-6 sticky top-6">
|
||||
<h2 className="text-xl font-semibold text-white">Order Summary</h2>
|
||||
|
||||
{hasPickupItems && (
|
||||
<div className="mt-5">
|
||||
{hasShedPickupItems && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
|
||||
<p className="text-sm font-medium text-emerald-400">
|
||||
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
Shed Pickup
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-zinc-400">
|
||||
{cart
|
||||
.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedStop && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
|
||||
<p className="text-sm font-medium text-emerald-400">
|
||||
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
Pickup: {selectedStop.city}, {selectedStop.state}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-zinc-400">{selectedStop.date} · {selectedStop.time}</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-400">{selectedStop.location}</p>
|
||||
<button type="button"
|
||||
onClick={onChangeStop}
|
||||
className="mt-2.5 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
aria-label="Change pickup stop"
|
||||
>
|
||||
Change stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!selectedStop && hasStopPickupItems && (
|
||||
<button type="button"
|
||||
onClick={onChangeStop}
|
||||
className="w-full rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-left text-sm text-amber-400 hover:bg-amber-500/20 transition-all"
|
||||
aria-label="Select pickup stop"
|
||||
>
|
||||
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /></svg>
|
||||
No pickup stop selected — click to choose
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
|
||||
<span className="text-sm font-semibold text-white">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="border-t border-white/5 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-white">Total</span>
|
||||
<span className="text-2xl font-bold text-white">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
onClick={onCheckout}
|
||||
disabled={checkoutDisabled}
|
||||
className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5"
|
||||
aria-label={
|
||||
cart.length === 0
|
||||
? "Your cart is empty. Add a product to continue."
|
||||
: incompatibleItems.length > 0
|
||||
? "Remove incompatible items first to proceed to checkout"
|
||||
: !selectedStop && hasStopPickupItems
|
||||
? "Select pickup stop first to proceed to checkout"
|
||||
: "Continue to checkout"
|
||||
}
|
||||
>
|
||||
{cart.length === 0
|
||||
? "Cart is Empty"
|
||||
: incompatibleItems.length > 0
|
||||
? "Remove Incompatible Items First"
|
||||
: !selectedStop && hasStopPickupItems
|
||||
? "Select Pickup Stop First"
|
||||
: "Continue to Checkout"}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 block w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Continue shopping"
|
||||
>
|
||||
Continue Shopping
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
+525
-377
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useCallback, useReducer } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
@@ -13,6 +13,73 @@ import StripeExpressCheckout, {
|
||||
type CheckoutItem as ExpressItem,
|
||||
} from "@/components/storefront/StripeExpressCheckout";
|
||||
|
||||
// ---- useReducer state ----
|
||||
type State = {
|
||||
stops: StopInfo[];
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
shippingState: string;
|
||||
shippingPostal: string;
|
||||
shippingCity: string;
|
||||
hostedLoading: boolean;
|
||||
hostedError: string | null;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_STOPS"; stops: StopInfo[] }
|
||||
| { type: "SET_NAME"; value: string }
|
||||
| { type: "SET_EMAIL"; value: string }
|
||||
| { type: "SET_PHONE"; value: string }
|
||||
| { type: "SET_SHIPPING_STATE"; value: string }
|
||||
| { type: "SET_SHIPPING_POSTAL"; value: string }
|
||||
| { type: "SET_SHIPPING_CITY"; value: string }
|
||||
| { type: "SET_HOSTED_LOADING"; value: boolean }
|
||||
| { type: "SET_HOSTED_ERROR"; value: string | null };
|
||||
|
||||
function initialState(): State {
|
||||
return {
|
||||
stops: [],
|
||||
customerName: "",
|
||||
customerEmail: "",
|
||||
customerPhone: "",
|
||||
shippingState: "",
|
||||
shippingPostal: "",
|
||||
shippingCity: "",
|
||||
hostedLoading: false,
|
||||
hostedError: null,
|
||||
// Stable idempotency key per checkout session — survives retries.
|
||||
// Use a lazy initializer with a guard so SSR returns "" and the
|
||||
// client fills in a UUID on first render. No useEffect needed.
|
||||
idempotencyKey:
|
||||
typeof window === "undefined" ? "" : crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_STOPS":
|
||||
return { ...state, stops: action.stops };
|
||||
case "SET_NAME":
|
||||
return { ...state, customerName: action.value };
|
||||
case "SET_EMAIL":
|
||||
return { ...state, customerEmail: action.value };
|
||||
case "SET_PHONE":
|
||||
return { ...state, customerPhone: action.value };
|
||||
case "SET_SHIPPING_STATE":
|
||||
return { ...state, shippingState: action.value };
|
||||
case "SET_SHIPPING_POSTAL":
|
||||
return { ...state, shippingPostal: action.value };
|
||||
case "SET_SHIPPING_CITY":
|
||||
return { ...state, shippingCity: action.value };
|
||||
case "SET_HOSTED_LOADING":
|
||||
return { ...state, hostedLoading: action.value };
|
||||
case "SET_HOSTED_ERROR":
|
||||
return { ...state, hostedError: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function CheckoutClient() {
|
||||
const cartContext = useCart();
|
||||
const cart = cartContext.cart;
|
||||
@@ -21,28 +88,7 @@ export default function CheckoutClient() {
|
||||
const setSelectedStop = cartContext.setSelectedStop;
|
||||
const cartBrandId = cartContext.cartBrandId;
|
||||
const router = useRouter();
|
||||
const [stops, setStops] = useState<StopInfo[]>([]);
|
||||
|
||||
// Controlled form state — passed down to StripeExpressCheckout so the
|
||||
// Express + Card paths know who the buyer is and where to ship / pick up.
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [customerEmail, setCustomerEmail] = useState("");
|
||||
const [customerPhone, setCustomerPhone] = useState("");
|
||||
const [shippingState, setShippingState] = useState("");
|
||||
const [shippingPostal, setShippingPostal] = useState("");
|
||||
const [shippingCity, setShippingCity] = useState("");
|
||||
|
||||
// Hosted-checkout (legacy Stripe Checkout) fallback state
|
||||
const [hostedLoading, setHostedLoading] = useState(false);
|
||||
const [hostedError, setHostedError] = useState<string | null>(null);
|
||||
|
||||
// Stable idempotency key per checkout session — survives retries.
|
||||
// Use a lazy initializer with a guard so SSR returns "" and the
|
||||
// client fills in a UUID on first render. No useEffect needed.
|
||||
const [idempotencyKey, setIdempotencyKey] = useState<string>(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return crypto.randomUUID();
|
||||
});
|
||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial stops load on mount (and on brand change). Cleanup
|
||||
@@ -52,17 +98,16 @@ export default function CheckoutClient() {
|
||||
let cancelled = false;
|
||||
getPublicStopsForBrand(cartBrandId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setStops(data ?? []);
|
||||
if (!cancelled) dispatch({ type: "SET_STOPS", stops: data ?? [] });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStops([]);
|
||||
if (!cancelled) dispatch({ type: "SET_STOPS", stops: [] });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cartBrandId]);
|
||||
|
||||
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
|
||||
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
|
||||
const hasStopPickupItems = cart.some(
|
||||
(i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"
|
||||
@@ -84,17 +129,17 @@ export default function CheckoutClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customerEmail.trim()) {
|
||||
setHostedError("Please enter your email before continuing to checkout.");
|
||||
if (!state.customerEmail.trim()) {
|
||||
dispatch({ type: "SET_HOSTED_ERROR", value: "Please enter your email before continuing to checkout." });
|
||||
return;
|
||||
}
|
||||
if (hasStopPickupItems && !selectedStop) {
|
||||
setHostedError("Please select a pickup location.");
|
||||
dispatch({ type: "SET_HOSTED_ERROR", value: "Please select a pickup location." });
|
||||
return;
|
||||
}
|
||||
|
||||
setHostedLoading(true);
|
||||
setHostedError(null);
|
||||
dispatch({ type: "SET_HOSTED_LOADING", value: true });
|
||||
dispatch({ type: "SET_HOSTED_ERROR", value: null });
|
||||
|
||||
const items = cart.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -113,9 +158,9 @@ export default function CheckoutClient() {
|
||||
sessionStorage.setItem(
|
||||
"pending_checkout:v1",
|
||||
JSON.stringify({
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
customerName: state.customerName,
|
||||
customerEmail: state.customerEmail,
|
||||
customerPhone: state.customerPhone,
|
||||
stopId: selectedStop?.id ?? null,
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
@@ -123,9 +168,9 @@ export default function CheckoutClient() {
|
||||
})),
|
||||
cartBrandId,
|
||||
shippingAddress: hasShipItems
|
||||
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
|
||||
? { state: state.shippingState, postal_code: state.shippingPostal, city: state.shippingCity }
|
||||
: undefined,
|
||||
idempotencyKey: idempotencyKey,
|
||||
idempotencyKey: state.idempotencyKey,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -138,46 +183,31 @@ export default function CheckoutClient() {
|
||||
cancelUrl
|
||||
);
|
||||
if (!stripeResult.success || !stripeResult.url) {
|
||||
setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
|
||||
setHostedLoading(false);
|
||||
dispatch({ type: "SET_HOSTED_ERROR", value: stripeResult.error ?? "Failed to initiate payment. Please try again." });
|
||||
dispatch({ type: "SET_HOSTED_LOADING", value: false });
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = stripeResult.url;
|
||||
}, [
|
||||
cart,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
state.customerName,
|
||||
state.customerEmail,
|
||||
state.customerPhone,
|
||||
state.shippingState,
|
||||
state.shippingPostal,
|
||||
state.shippingCity,
|
||||
state.idempotencyKey,
|
||||
selectedStop,
|
||||
shippingState,
|
||||
shippingPostal,
|
||||
shippingCity,
|
||||
cartBrandId,
|
||||
hasShipItems,
|
||||
hasStopPickupItems,
|
||||
cartBrandId,
|
||||
router,
|
||||
setSelectedStop,
|
||||
idempotencyKey,
|
||||
]);
|
||||
|
||||
if (cart.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
||||
>
|
||||
← Back to storefront
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <EmptyCartView />;
|
||||
}
|
||||
|
||||
// Map cart → StripeExpressCheckout item shape
|
||||
@@ -194,324 +224,442 @@ export default function CheckoutClient() {
|
||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
||||
{/* LEFT — Form + Embedded Stripe Elements */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure — we never see them.
|
||||
</p>
|
||||
|
||||
{/* Error alert (shared across both payment paths) */}
|
||||
{(hostedError) && (
|
||||
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{hostedError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
{/* Customer Information — controlled inputs feed the embedded
|
||||
Stripe Elements above. The wallet (Apple Pay) will overwrite
|
||||
name/email at confirm time, but we still collect them here
|
||||
so the order row has them in case the wallet omits them. */}
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Your details</legend>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Used for the order confirmation. Apple Pay / Google Pay will fill these in for you.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
|
||||
Full Name
|
||||
</label>
|
||||
<input aria-label="Jane Smith"
|
||||
id="customer_name"
|
||||
name="customer_name"
|
||||
autoComplete="name"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="Jane Smith"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
|
||||
Email <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input aria-label="Jane@example.com"
|
||||
id="customer_email"
|
||||
name="customer_email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={customerEmail}
|
||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="jane@example.com"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customer_phone" className="block text-sm font-medium text-slate-700">
|
||||
Phone <span className="text-slate-400 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input aria-label="(555) 555 5555"
|
||||
id="customer_phone"
|
||||
name="customer_phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={customerPhone}
|
||||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="(555) 555-5555"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Shed Pickup notice */}
|
||||
{hasShedPickupItems && (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{cart.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
).join(", ")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Pickup location details are included in your order confirmation.</p>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Stop Pickup */}
|
||||
{hasStopPickupItems && (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||
<legend className="text-xl font-bold text-slate-900">
|
||||
Pickup Location <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</legend>
|
||||
|
||||
{selectedStop ? (
|
||||
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<p className="font-medium text-emerald-900">
|
||||
<svg className="inline h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
{selectedStop.city}, {selectedStop.state}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-emerald-700">
|
||||
{selectedStop.date} · {selectedStop.location}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStop(null)}
|
||||
className="mt-2 text-xs text-emerald-600 hover:text-emerald-800 underline transition-colors"
|
||||
aria-label="Change pickup stop"
|
||||
>
|
||||
Change pickup stop
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<label htmlFor="stop_id" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Select pickup stop <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select aria-label="Stop Id"
|
||||
id="stop_id"
|
||||
name="stop_id"
|
||||
required
|
||||
value={(selectedStop as StopInfo | null)?.id ?? ""}
|
||||
onChange={(e) => {
|
||||
const found = stops.find((s) => s.id === e.target.value) ?? null;
|
||||
setSelectedStop(found as StopInfo | null);
|
||||
}}
|
||||
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
|
||||
aria-required="true"
|
||||
>
|
||||
<option value="">Select a stop...</option>
|
||||
{stops.map((stop) => (
|
||||
<option key={stop.id} value={stop.id}>
|
||||
{stop.city}, {stop.state} — {stop.date} @ {stop.location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Shipping Address */}
|
||||
{hasShipItems && (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Shipping Address</legend>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Enter your shipping details for delivery.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
||||
Street Address
|
||||
</label>
|
||||
<input aria-label="123 Main St"
|
||||
id="shipping_address"
|
||||
name="shipping_address"
|
||||
autoComplete="street-address"
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||||
City
|
||||
</label>
|
||||
<input aria-label="Shipping City"
|
||||
id="shipping_city"
|
||||
name="shipping_city"
|
||||
autoComplete="address-level2"
|
||||
value={shippingCity}
|
||||
onChange={(e) => setShippingCity(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||||
State
|
||||
</label>
|
||||
<input aria-label="NC"
|
||||
id="shipping_state"
|
||||
name="shipping_state"
|
||||
autoComplete="address-level1"
|
||||
maxLength={2}
|
||||
value={shippingState}
|
||||
onChange={(e) => setShippingState(e.target.value.toUpperCase())}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
|
||||
placeholder="NC"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||||
ZIP Code
|
||||
</label>
|
||||
<input aria-label="28147"
|
||||
id="shipping_postal_code"
|
||||
name="shipping_postal_code"
|
||||
autoComplete="postal-code"
|
||||
value={shippingPostal}
|
||||
onChange={(e) => setShippingPostal(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="28147"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
|
||||
<div data-testid="stripe-express-checkout">
|
||||
<StripeExpressCheckout
|
||||
items={expressItems}
|
||||
brandId={cartBrandId}
|
||||
customerName={customerName}
|
||||
customerEmail={customerEmail}
|
||||
customerPhone={customerPhone}
|
||||
selectedStop={selectedStop as StopInfo | null}
|
||||
shippingState={shippingState}
|
||||
shippingPostal={shippingPostal}
|
||||
shippingCity={shippingCity}
|
||||
checkoutSessionKey={idempotencyKey || undefined}
|
||||
onHostedCheckout={() => void handleHostedCheckout()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hosted-checkout fallback (legacy Stripe Checkout) */}
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
|
||||
<p className="text-xs text-slate-500 mb-2 text-center">
|
||||
Prefer Stripe’s hosted page? Tap below.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleHostedCheckout()}
|
||||
disabled={hostedLoading}
|
||||
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{hostedLoading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
|
||||
Redirecting to Stripe…
|
||||
</>
|
||||
) : (
|
||||
<>Use secure hosted checkout →</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<aside aria-label="Order summary">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
|
||||
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{cart.map((item) => (
|
||||
<div key={item.id} className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">
|
||||
{item.name} × {item.quantity}
|
||||
{item.fulfillment === "ship" && (
|
||||
<span className="ml-1 text-xs text-blue-500">(ship)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium text-slate-900">
|
||||
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-slate-200 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Subtotal</span>
|
||||
<span className="text-slate-900">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Estimated Tax</span>
|
||||
<span className="text-slate-900 text-xs italic">Calculated at payment</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-bold text-slate-900 pt-2 border-t border-slate-200">
|
||||
<span>Total</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust badges */}
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Secured by Stripe</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>100% Sweetness Guarantee</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<CheckoutForm
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
cart={cart}
|
||||
cartBrandId={cartBrandId}
|
||||
selectedStop={selectedStop}
|
||||
setSelectedStop={setSelectedStop}
|
||||
stops={state.stops}
|
||||
hasShedPickupItems={hasShedPickupItems}
|
||||
hasStopPickupItems={hasStopPickupItems}
|
||||
hasShipItems={hasShipItems}
|
||||
expressItems={expressItems}
|
||||
onHostedCheckout={() => void handleHostedCheckout()}
|
||||
/>
|
||||
<OrderSummary cart={cart} subtotal={subtotal} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyCartView() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
||||
>
|
||||
← Back to storefront
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CartItemLike = { id: string; name: string; price: string; quantity: number; fulfillment?: string; description?: string; pickup_type?: string };
|
||||
|
||||
type CheckoutFormProps = {
|
||||
state: State;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
cart: CartItemLike[];
|
||||
cartBrandId: string | null | undefined;
|
||||
selectedStop: StopInfo | null;
|
||||
setSelectedStop: (stop: StopInfo | null) => void;
|
||||
stops: StopInfo[];
|
||||
hasShedPickupItems: boolean;
|
||||
hasStopPickupItems: boolean;
|
||||
hasShipItems: boolean;
|
||||
expressItems: ExpressItem[];
|
||||
onHostedCheckout: () => void;
|
||||
};
|
||||
|
||||
function CheckoutForm({
|
||||
state,
|
||||
dispatch,
|
||||
cart,
|
||||
cartBrandId,
|
||||
selectedStop,
|
||||
setSelectedStop,
|
||||
stops,
|
||||
hasShedPickupItems,
|
||||
hasStopPickupItems,
|
||||
hasShipItems,
|
||||
expressItems,
|
||||
onHostedCheckout,
|
||||
}: CheckoutFormProps) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure — we never see them.
|
||||
</p>
|
||||
|
||||
{/* Error alert (shared across both payment paths) */}
|
||||
{state.hostedError && (
|
||||
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{state.hostedError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<CustomerDetailsFieldset state={state} dispatch={dispatch} />
|
||||
{hasShedPickupItems && <ShedPickupNotice cart={cart} />}
|
||||
{hasStopPickupItems && (
|
||||
<StopPickupFieldset
|
||||
selectedStop={selectedStop}
|
||||
setSelectedStop={setSelectedStop}
|
||||
stops={stops}
|
||||
/>
|
||||
)}
|
||||
{hasShipItems && <ShippingAddressFieldset state={state} dispatch={dispatch} />}
|
||||
|
||||
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
|
||||
<div data-testid="stripe-express-checkout">
|
||||
<StripeExpressCheckout
|
||||
items={expressItems}
|
||||
brandId={cartBrandId ?? null}
|
||||
customerName={state.customerName}
|
||||
customerEmail={state.customerEmail}
|
||||
customerPhone={state.customerPhone}
|
||||
selectedStop={selectedStop as StopInfo | null}
|
||||
shippingState={state.shippingState}
|
||||
shippingPostal={state.shippingPostal}
|
||||
shippingCity={state.shippingCity}
|
||||
checkoutSessionKey={state.idempotencyKey || undefined}
|
||||
onHostedCheckout={onHostedCheckout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HostedCheckoutFallback
|
||||
hostedLoading={state.hostedLoading}
|
||||
onClick={onHostedCheckout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CustomerDetailsFieldsetProps = {
|
||||
state: State;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
};
|
||||
|
||||
function CustomerDetailsFieldset({ state, dispatch }: CustomerDetailsFieldsetProps) {
|
||||
return (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Your details</legend>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Used for the order confirmation. Apple Pay / Google Pay will fill these in for you.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
|
||||
Full Name
|
||||
</label>
|
||||
<input aria-label="Jane Smith"
|
||||
id="customer_name"
|
||||
name="customer_name"
|
||||
autoComplete="name"
|
||||
value={state.customerName}
|
||||
onChange={(e) => dispatch({ type: "SET_NAME", value: e.target.value })}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="Jane Smith"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
|
||||
Email <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input aria-label="Jane@example.com"
|
||||
id="customer_email"
|
||||
name="customer_email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={state.customerEmail}
|
||||
onChange={(e) => dispatch({ type: "SET_EMAIL", value: e.target.value })}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="jane@example.com"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customer_phone" className="block text-sm font-medium text-slate-700">
|
||||
Phone <span className="text-slate-400 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input aria-label="(555) 555 5555"
|
||||
id="customer_phone"
|
||||
name="customer_phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={state.customerPhone}
|
||||
onChange={(e) => dispatch({ type: "SET_PHONE", value: e.target.value })}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="(555) 555-5555"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function ShedPickupNotice({ cart }: { cart: CartItemLike[] }) {
|
||||
return (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{cart.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
).join(", ")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Pickup location details are included in your order confirmation.</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
type StopPickupFieldsetProps = {
|
||||
selectedStop: StopInfo | null;
|
||||
setSelectedStop: (stop: StopInfo | null) => void;
|
||||
stops: StopInfo[];
|
||||
};
|
||||
|
||||
function StopPickupFieldset({ selectedStop, setSelectedStop, stops }: StopPickupFieldsetProps) {
|
||||
return (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||
<legend className="text-xl font-bold text-slate-900">
|
||||
Pickup Location <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</legend>
|
||||
|
||||
{selectedStop ? (
|
||||
<SelectedStopCard
|
||||
selectedStop={selectedStop}
|
||||
onChange={() => setSelectedStop(null)}
|
||||
/>
|
||||
) : (
|
||||
<StopSelector stops={stops} setSelectedStop={setSelectedStop} />
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedStopCard({ selectedStop, onChange }: { selectedStop: StopInfo; onChange: () => void }) {
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<p className="font-medium text-emerald-900">
|
||||
<svg className="inline h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
{selectedStop.city}, {selectedStop.state}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-emerald-700">
|
||||
{selectedStop.date} · {selectedStop.location}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className="mt-2 text-xs text-emerald-600 hover:text-emerald-800 underline transition-colors"
|
||||
aria-label="Change pickup stop"
|
||||
>
|
||||
Change pickup stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StopSelector({ stops, setSelectedStop }: { stops: StopInfo[]; setSelectedStop: (stop: StopInfo | null) => void }) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<label htmlFor="stop_id" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Select pickup stop <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select aria-label="Stop Id"
|
||||
id="stop_id"
|
||||
name="stop_id"
|
||||
required
|
||||
value={""}
|
||||
onChange={(e) => {
|
||||
const found = stops.find((s) => s.id === e.target.value) ?? null;
|
||||
setSelectedStop(found as StopInfo | null);
|
||||
}}
|
||||
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
|
||||
aria-required="true"
|
||||
>
|
||||
<option value="">Select a stop...</option>
|
||||
{stops.map((stop) => (
|
||||
<option key={stop.id} value={stop.id}>
|
||||
{stop.city}, {stop.state} — {stop.date} @ {stop.location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShippingAddressFieldset({ state, dispatch }: { state: State; dispatch: React.Dispatch<Action> }) {
|
||||
return (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Shipping Address</legend>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Enter your shipping details for delivery.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
||||
Street Address
|
||||
</label>
|
||||
<input aria-label="123 Main St"
|
||||
id="shipping_address"
|
||||
name="shipping_address"
|
||||
autoComplete="street-address"
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||||
City
|
||||
</label>
|
||||
<input aria-label="Shipping City"
|
||||
id="shipping_city"
|
||||
name="shipping_city"
|
||||
autoComplete="address-level2"
|
||||
value={state.shippingCity}
|
||||
onChange={(e) => dispatch({ type: "SET_SHIPPING_CITY", value: e.target.value })}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||||
State
|
||||
</label>
|
||||
<input aria-label="NC"
|
||||
id="shipping_state"
|
||||
name="shipping_state"
|
||||
autoComplete="address-level1"
|
||||
maxLength={2}
|
||||
value={state.shippingState}
|
||||
onChange={(e) => dispatch({ type: "SET_SHIPPING_STATE", value: e.target.value.toUpperCase() })}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
|
||||
placeholder="NC"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||||
ZIP Code
|
||||
</label>
|
||||
<input aria-label="28147"
|
||||
id="shipping_postal_code"
|
||||
name="shipping_postal_code"
|
||||
autoComplete="postal-code"
|
||||
value={state.shippingPostal}
|
||||
onChange={(e) => dispatch({ type: "SET_SHIPPING_POSTAL", value: e.target.value })}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="28147"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedCheckoutFallback({ hostedLoading, onClick }: { hostedLoading: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
|
||||
<p className="text-xs text-slate-500 mb-2 text-center">
|
||||
Prefer Stripe's hosted page? Tap below.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={hostedLoading}
|
||||
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{hostedLoading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
|
||||
Redirecting to Stripe…
|
||||
</>
|
||||
) : (
|
||||
<>Use secure hosted checkout →</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderSummary({ cart, subtotal }: { cart: CartItemLike[]; subtotal: number }) {
|
||||
return (
|
||||
<aside aria-label="Order summary">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
|
||||
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{cart.map((item) => (
|
||||
<div key={item.id} className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">
|
||||
{item.name} × {item.quantity}
|
||||
{item.fulfillment === "ship" && (
|
||||
<span className="ml-1 text-xs text-blue-500">(ship)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium text-slate-900">
|
||||
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-slate-200 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Subtotal</span>
|
||||
<span className="text-slate-900">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Estimated Tax</span>
|
||||
<span className="text-slate-900 text-xs italic">Calculated at payment</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-bold text-slate-900 pt-2 border-t border-slate-200">
|
||||
<span>Total</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust badges */}
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Secured by Stripe</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>100% Sweetness Guarantee</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { useEffect, useReducer, Suspense, lazy } from "react";
|
||||
import Image from "next/image";
|
||||
import { m as motion } from "framer-motion";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
@@ -15,30 +15,76 @@ const ValuesSection = lazy(() => import("@/components/about/IndianRiverValuesSec
|
||||
const ContactSection = lazy(() => import("@/components/about/IndianRiverContactSection"));
|
||||
const CTASection = lazy(() => import("@/components/about/IndianRiverCTASection"));
|
||||
|
||||
type State = {
|
||||
logoUrl: string | null;
|
||||
logoUrlDark: string | null;
|
||||
showWholesaleLink: boolean;
|
||||
isAdmin: boolean;
|
||||
customFooterText: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: "HYDRATE_BRAND";
|
||||
logoUrl: string | null;
|
||||
logoUrlDark: string | null;
|
||||
showWholesaleLink: boolean;
|
||||
customFooterText: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
}
|
||||
| { type: "SET_ADMIN"; value: boolean };
|
||||
|
||||
const initialState: State = {
|
||||
logoUrl: null,
|
||||
logoUrlDark: null,
|
||||
showWholesaleLink: true,
|
||||
isAdmin: false,
|
||||
customFooterText: null,
|
||||
contactEmail: null,
|
||||
contactPhone: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "HYDRATE_BRAND":
|
||||
return {
|
||||
...state,
|
||||
logoUrl: action.logoUrl,
|
||||
logoUrlDark: action.logoUrlDark,
|
||||
showWholesaleLink: action.showWholesaleLink,
|
||||
customFooterText: action.customFooterText,
|
||||
contactEmail: action.contactEmail,
|
||||
contactPhone: action.contactPhone,
|
||||
};
|
||||
case "SET_ADMIN":
|
||||
return { ...state, isAdmin: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function IndianRiverAboutPage() {
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
|
||||
const [showWholesaleLink, setShowWholesaleLink] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [customFooterText, setCustomFooterText] = useState<string | null>(null);
|
||||
const [contactEmail, setContactEmail] = useState<string | null>(null);
|
||||
const [contactPhone, setContactPhone] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const settingsResult = await getBrandSettingsPublic("indian-river-direct");
|
||||
if (settingsResult.success && settingsResult.settings) {
|
||||
const s = settingsResult.settings;
|
||||
setLogoUrl(s.logo_url ?? null);
|
||||
setLogoUrlDark(s.logo_url_dark ?? null);
|
||||
setCustomFooterText(s.custom_footer_text ?? null);
|
||||
setContactEmail(s.email ?? null);
|
||||
setContactPhone(s.phone ?? null);
|
||||
setShowWholesaleLink(s.show_wholesale_link ?? true);
|
||||
dispatch({
|
||||
type: "HYDRATE_BRAND",
|
||||
logoUrl: s.logo_url ?? null,
|
||||
logoUrlDark: s.logo_url_dark ?? null,
|
||||
showWholesaleLink: s.show_wholesale_link ?? true,
|
||||
customFooterText: s.custom_footer_text ?? null,
|
||||
contactEmail: s.email ?? null,
|
||||
contactPhone: s.phone ?? null,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { getCurrentAdminUser } = await import("@/actions/admin-user");
|
||||
setIsAdmin(!!await getCurrentAdminUser());
|
||||
try {
|
||||
const { getCurrentAdminUser } = await import("@/actions/admin-user");
|
||||
dispatch({ type: "SET_ADMIN", value: !!(await getCurrentAdminUser()) });
|
||||
} catch { /* not logged in */ }
|
||||
}
|
||||
load();
|
||||
@@ -46,14 +92,14 @@ export default function IndianRiverAboutPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader
|
||||
brandName="Indian River Direct"
|
||||
brandSlug="indian-river-direct"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
showWholesaleLink={showWholesaleLink}
|
||||
isAdmin={isAdmin}
|
||||
brandAccent="blue"
|
||||
<StorefrontHeader
|
||||
brandName="Indian River Direct"
|
||||
brandSlug="indian-river-direct"
|
||||
logoUrl={state.logoUrl}
|
||||
logoUrlDark={state.logoUrlDark}
|
||||
showWholesaleLink={state.showWholesaleLink}
|
||||
isAdmin={state.isAdmin}
|
||||
brandAccent="blue"
|
||||
/>
|
||||
|
||||
<main>
|
||||
@@ -113,9 +159,9 @@ export default function IndianRiverAboutPage() {
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-6 bg-blue-900/30 rounded-full blur-2xl" />
|
||||
{logoUrlDark ? (
|
||||
{state.logoUrlDark ? (
|
||||
<Image
|
||||
src={logoUrlDark}
|
||||
src={state.logoUrlDark}
|
||||
alt="Indian River Direct"
|
||||
width={288}
|
||||
height={288}
|
||||
@@ -158,7 +204,7 @@ export default function IndianRiverAboutPage() {
|
||||
|
||||
{/* Contact Section */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-stone-950" />}>
|
||||
<ContactSection phone={contactPhone} email={contactEmail} />
|
||||
<ContactSection phone={state.contactPhone} email={state.contactEmail} />
|
||||
</Suspense>
|
||||
|
||||
{/* CTA Section */}
|
||||
@@ -167,16 +213,16 @@ export default function IndianRiverAboutPage() {
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter
|
||||
brandName="Indian River Direct"
|
||||
brandSlug="indian-river-direct"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
customFooterText={customFooterText}
|
||||
contactEmail={contactEmail}
|
||||
contactPhone={contactPhone}
|
||||
isAdmin={isAdmin}
|
||||
brandAccent="blue"
|
||||
<StorefrontFooter
|
||||
brandName="Indian River Direct"
|
||||
brandSlug="indian-river-direct"
|
||||
logoUrl={state.logoUrl}
|
||||
logoUrlDark={state.logoUrlDark}
|
||||
customFooterText={state.customFooterText}
|
||||
contactEmail={state.contactEmail}
|
||||
contactPhone={state.contactPhone}
|
||||
isAdmin={state.isAdmin}
|
||||
brandAccent="blue"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { m as motion } from "framer-motion";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
@@ -22,43 +22,111 @@ type FormState = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
brandSettings: BrandSettings | null;
|
||||
submitted: boolean;
|
||||
form: FormState;
|
||||
isSubmitting: boolean;
|
||||
logoUrl: string | null;
|
||||
isAdmin: boolean;
|
||||
customFooterText: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_BRAND_SETTINGS"; value: BrandSettings | null }
|
||||
| { type: "SET_LOGO_URL"; value: string | null }
|
||||
| { type: "SET_ADMIN"; value: boolean }
|
||||
| { type: "SET_CUSTOM_FOOTER"; value: string | null }
|
||||
| { type: "SET_CONTACT_EMAIL"; value: string | null }
|
||||
| { type: "SET_CONTACT_PHONE"; value: string | null }
|
||||
| { type: "SET_FORM_NAME"; value: string }
|
||||
| { type: "SET_FORM_EMAIL"; value: string }
|
||||
| { type: "SET_FORM_TOPIC"; value: string }
|
||||
| { type: "SET_FORM_MESSAGE"; value: string }
|
||||
| { type: "RESET_FORM" }
|
||||
| { type: "START_SUBMIT" }
|
||||
| { type: "SUBMIT_COMPLETE" };
|
||||
|
||||
const initialForm: FormState = { name: "", email: "", topic: "general", message: "" };
|
||||
|
||||
const initialState: State = {
|
||||
brandSettings: null,
|
||||
submitted: false,
|
||||
form: initialForm,
|
||||
isSubmitting: false,
|
||||
logoUrl: null,
|
||||
isAdmin: false,
|
||||
customFooterText: null,
|
||||
contactEmail: null,
|
||||
contactPhone: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_BRAND_SETTINGS":
|
||||
return { ...state, brandSettings: action.value };
|
||||
case "SET_LOGO_URL":
|
||||
return { ...state, logoUrl: action.value };
|
||||
case "SET_ADMIN":
|
||||
return { ...state, isAdmin: action.value };
|
||||
case "SET_CUSTOM_FOOTER":
|
||||
return { ...state, customFooterText: action.value };
|
||||
case "SET_CONTACT_EMAIL":
|
||||
return { ...state, contactEmail: action.value };
|
||||
case "SET_CONTACT_PHONE":
|
||||
return { ...state, contactPhone: action.value };
|
||||
case "SET_FORM_NAME":
|
||||
return { ...state, form: { ...state.form, name: action.value } };
|
||||
case "SET_FORM_EMAIL":
|
||||
return { ...state, form: { ...state.form, email: action.value } };
|
||||
case "SET_FORM_TOPIC":
|
||||
return { ...state, form: { ...state.form, topic: action.value } };
|
||||
case "SET_FORM_MESSAGE":
|
||||
return { ...state, form: { ...state.form, message: action.value } };
|
||||
case "RESET_FORM":
|
||||
return { ...state, submitted: false, form: initialForm };
|
||||
case "START_SUBMIT":
|
||||
return { ...state, isSubmitting: true };
|
||||
case "SUBMIT_COMPLETE":
|
||||
return { ...state, isSubmitting: false, submitted: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default function IndianRiverContactPage() {
|
||||
const [brandSettings, setBrandSettings] = useState<BrandSettings | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [form, setForm] = useState<FormState>({ name: "", email: "", topic: "general", message: "" });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [customFooterText, setCustomFooterText] = useState<string | null>(null);
|
||||
const [contactEmail, setContactEmail] = useState<string | null>(null);
|
||||
const [contactPhone, setContactPhone] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from("brands").select("id").eq("slug", "indian-river-direct").single().then(({ data: brand }) => {
|
||||
if (!brand?.id) return;
|
||||
supabase.from("wholesale_settings")
|
||||
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
|
||||
.eq("brand_id", brand.id).single().then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
|
||||
.eq("brand_id", brand.id).single().then(({ data }) => dispatch({ type: "SET_BRAND_SETTINGS", value: (data as unknown as BrandSettings) ?? null }));
|
||||
supabase.from("brand_settings").select("logo_url, custom_footer_text, email, phone")
|
||||
.eq("brand_id", brand.id).single().then(({ data: s }) => {
|
||||
if (s) { setLogoUrl((s as { logo_url: string | null }).logo_url ?? null); setCustomFooterText((s as { custom_footer_text: string | null }).custom_footer_text ?? null); setContactEmail((s as { email: string | null }).email ?? null); setContactPhone((s as { phone: string | null }).phone ?? null); }
|
||||
if (s) {
|
||||
dispatch({ type: "SET_LOGO_URL", value: (s as { logo_url: string | null }).logo_url ?? null });
|
||||
dispatch({ type: "SET_CUSTOM_FOOTER", value: (s as { custom_footer_text: string | null }).custom_footer_text ?? null });
|
||||
dispatch({ type: "SET_CONTACT_EMAIL", value: (s as { email: string | null }).email ?? null });
|
||||
dispatch({ type: "SET_CONTACT_PHONE", value: (s as { phone: string | null }).phone ?? null });
|
||||
}
|
||||
});
|
||||
try { import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u)); }); } catch { /* not logged in */ }
|
||||
try { import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { getCurrentAdminUser().then((u: unknown) => dispatch({ type: "SET_ADMIN", value: !!u })); }); } catch { /* not logged in */ }
|
||||
});
|
||||
}, []);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
dispatch({ type: "START_SUBMIT" });
|
||||
setTimeout(() => {
|
||||
setSubmitted(true);
|
||||
setIsSubmitting(false);
|
||||
dispatch({ type: "SUBMIT_COMPLETE" });
|
||||
}, 600);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Indian River Direct" brandSlug="indian-river-direct" logoUrl={logoUrl} showWholesaleLink={true} isAdmin={isAdmin} brandAccent="blue" />
|
||||
<StorefrontHeader brandName="Indian River Direct" brandSlug="indian-river-direct" logoUrl={state.logoUrl} showWholesaleLink={true} isAdmin={state.isAdmin} brandAccent="blue" />
|
||||
|
||||
<main className="px-6 py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
@@ -84,19 +152,19 @@ export default function IndianRiverContactPage() {
|
||||
{[
|
||||
{
|
||||
label: "Address",
|
||||
value: brandSettings?.invoice_business_address ?? "Indian River Region, Florida",
|
||||
value: state.brandSettings?.invoice_business_address ?? "Indian River Region, Florida",
|
||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />,
|
||||
iconPath: <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
},
|
||||
{
|
||||
label: "Phone",
|
||||
value: contactPhone ?? "772-971-4484",
|
||||
value: state.contactPhone ?? "772-971-4484",
|
||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.184-.046-.379-.041-.545.114L18 10.48a2.25 2.25 0 00-.545-.114l-4.423 1.106c-.5.119-.852.575-.852 1.091v1.372a2.25 2.25 0 01-2.25 2.25h-2.25a15 15 0 01-15-15z" />,
|
||||
iconPath: null
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com",
|
||||
value: state.brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com",
|
||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />,
|
||||
iconPath: null
|
||||
},
|
||||
@@ -136,7 +204,7 @@ export default function IndianRiverContactPage() {
|
||||
</h2>
|
||||
<div className="mt-4 h-px w-12 bg-blue-600" />
|
||||
</div>
|
||||
{submitted ? (
|
||||
{state.submitted ? (
|
||||
<div className="rounded-2xl bg-blue-50 border-2 border-blue-100 p-10 text-center shadow-md">
|
||||
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 mb-4 shadow-md">
|
||||
<svg className="h-7 w-7 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -146,10 +214,7 @@ export default function IndianRiverContactPage() {
|
||||
<p className="text-xl font-bold text-stone-950">Message received.</p>
|
||||
<p className="mt-2 text-base text-stone-500">We will be in touch within 1-2 business days.</p>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
setSubmitted(false);
|
||||
setForm({ name: "", email: "", topic: "general", message: "" });
|
||||
}}
|
||||
onClick={() => dispatch({ type: "RESET_FORM" })}
|
||||
className="mt-5 text-sm font-semibold text-blue-700 underline hover:text-blue-900"
|
||||
>
|
||||
Send another message
|
||||
@@ -162,8 +227,8 @@ export default function IndianRiverContactPage() {
|
||||
<label htmlFor="fld-1-your-name" className="block text-sm font-semibold text-stone-700 mb-2">Your Name</label>
|
||||
<input id="fld-1-your-name" aria-label="Jane Smith"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
value={state.form.name}
|
||||
onChange={(e) => dispatch({ type: "SET_FORM_NAME", value: e.target.value })}
|
||||
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 placeholder:text-stone-400 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors"
|
||||
placeholder="Jane Smith"
|
||||
autoComplete="name"
|
||||
@@ -174,8 +239,8 @@ export default function IndianRiverContactPage() {
|
||||
<input id="fld-2-email" aria-label="Jane@example.com"
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
value={state.form.email}
|
||||
onChange={(e) => dispatch({ type: "SET_FORM_EMAIL", value: e.target.value })}
|
||||
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 placeholder:text-stone-400 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors"
|
||||
placeholder="jane@example.com"
|
||||
autoComplete="email"
|
||||
@@ -185,8 +250,8 @@ export default function IndianRiverContactPage() {
|
||||
<div>
|
||||
<label htmlFor="fld-3-topic" className="block text-sm font-semibold text-stone-700 mb-2">Topic</label>
|
||||
<select id="fld-3-topic" aria-label="Select"
|
||||
value={form.topic}
|
||||
onChange={(e) => setForm({ ...form, topic: e.target.value })}
|
||||
value={state.form.topic}
|
||||
onChange={(e) => dispatch({ type: "SET_FORM_TOPIC", value: e.target.value })}
|
||||
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors appearance-none"
|
||||
>
|
||||
<option value="general">General Question</option>
|
||||
@@ -200,18 +265,18 @@ export default function IndianRiverContactPage() {
|
||||
<textarea id="fld-4-message" aria-label="How Can We Help You?"
|
||||
required
|
||||
rows={5}
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
value={state.form.message}
|
||||
onChange={(e) => dispatch({ type: "SET_FORM_MESSAGE", value: e.target.value })}
|
||||
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 placeholder:text-stone-400 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors resize-none"
|
||||
placeholder="How can we help you?"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={state.isSubmitting}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-500 px-8 py-4 text-base font-bold text-white hover:shadow-lg hover:shadow-blue-600/25 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send Message"}
|
||||
{state.isSubmitting ? "Sending..." : "Send Message"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -269,7 +334,7 @@ export default function IndianRiverContactPage() {
|
||||
</LayoutContainer>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName="Indian River Direct" brandSlug="indian-river-direct" logoUrl={logoUrl} customFooterText={customFooterText} contactEmail={contactEmail} contactPhone={contactPhone} isAdmin={isAdmin} brandAccent="blue" />
|
||||
<StorefrontFooter brandName="Indian River Direct" brandSlug="indian-river-direct" logoUrl={state.logoUrl} customFooterText={state.customFooterText} contactEmail={state.contactEmail} contactPhone={state.contactPhone} isAdmin={state.isAdmin} brandAccent="blue" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { m as motion, useInView } from "framer-motion";
|
||||
@@ -13,15 +13,15 @@ import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
|
||||
type Brand = { id: string; name: string; slug: string };
|
||||
type Stop = { id: string; city: string; state: string; date: string; time: string; location: string; slug: string; brand_id: string };
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
type: string;
|
||||
image_url: string | null;
|
||||
brand_id: string;
|
||||
is_taxable?: boolean;
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
type: string;
|
||||
image_url: string | null;
|
||||
brand_id: string;
|
||||
is_taxable?: boolean;
|
||||
pickup_type?: "scheduled_stop" | "shed";
|
||||
seasonal?: boolean;
|
||||
season_start?: string;
|
||||
@@ -31,6 +31,37 @@ type Product = {
|
||||
pickup_only?: boolean;
|
||||
};
|
||||
|
||||
type State = {
|
||||
brand: Brand | null;
|
||||
stops: Stop[];
|
||||
products: Product[];
|
||||
logoUrl: string | null;
|
||||
logoUrlDark: string | null;
|
||||
showSchedulePdf: boolean;
|
||||
showWholesaleLink: boolean;
|
||||
heroTagline: string | null;
|
||||
isAdmin: boolean;
|
||||
customFooterText: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_BRAND"; brand: Brand | null }
|
||||
| { type: "SET_STOPS"; stops: Stop[] }
|
||||
| { type: "SET_PRODUCTS"; products: Product[] }
|
||||
| { type: "SET_BRAND_SETTINGS"; settings: {
|
||||
logoUrl: string | null;
|
||||
logoUrlDark: string | null;
|
||||
heroTagline: string | null;
|
||||
customFooterText: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
showSchedulePdf: boolean;
|
||||
showWholesaleLink: boolean;
|
||||
} }
|
||||
| { type: "SET_IS_ADMIN"; value: boolean };
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{ name: "Linda Hurlbut", text: "I finally got my grapefruit from you today. OMG thank you for bringing your fruit to us. It is amazing. We will see you next month & tell our friends.", rating: 5 },
|
||||
{ name: "Phil Myers", text: "I just wanted to comment on the citrus I received. They were absolutely the best I have got from you over the past few years!", rating: 5 },
|
||||
@@ -42,14 +73,54 @@ const SCHEDULE_PDFS = [
|
||||
{ region: "WI-IL", url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/2026_WI-IL_Schedule_Draft_Website.pdf" },
|
||||
];
|
||||
|
||||
const initialState: State = {
|
||||
brand: null,
|
||||
stops: [],
|
||||
products: [],
|
||||
logoUrl: null,
|
||||
logoUrlDark: null,
|
||||
showSchedulePdf: true,
|
||||
showWholesaleLink: true,
|
||||
heroTagline: null,
|
||||
isAdmin: false,
|
||||
customFooterText: null,
|
||||
contactEmail: null,
|
||||
contactPhone: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_BRAND":
|
||||
return { ...state, brand: action.brand };
|
||||
case "SET_STOPS":
|
||||
return { ...state, stops: action.stops };
|
||||
case "SET_PRODUCTS":
|
||||
return { ...state, products: action.products };
|
||||
case "SET_BRAND_SETTINGS":
|
||||
return {
|
||||
...state,
|
||||
logoUrl: action.settings.logoUrl,
|
||||
logoUrlDark: action.settings.logoUrlDark,
|
||||
heroTagline: action.settings.heroTagline,
|
||||
customFooterText: action.settings.customFooterText,
|
||||
contactEmail: action.settings.contactEmail,
|
||||
contactPhone: action.settings.contactPhone,
|
||||
showSchedulePdf: action.settings.showSchedulePdf,
|
||||
showWholesaleLink: action.settings.showWholesaleLink,
|
||||
};
|
||||
case "SET_IS_ADMIN":
|
||||
return { ...state, isAdmin: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll-triggered fade-in wrapper
|
||||
function FadeInSection({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
function FadeInSection({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -69,18 +140,7 @@ function FadeInSection({
|
||||
}
|
||||
|
||||
export default function IndianRiverDirectPage() {
|
||||
const [brand, setBrand] = useState<Brand | null>(null);
|
||||
const [stops, setStops] = useState<Stop[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
|
||||
const [showSchedulePdf, setShowSchedulePdf] = useState(true);
|
||||
const [showWholesaleLink, setShowWholesaleLink] = useState(true);
|
||||
const [heroTagline, setHeroTagline] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [customFooterText, setCustomFooterText] = useState<string | null>(null);
|
||||
const [contactEmail, setContactEmail] = useState<string | null>(null);
|
||||
const [contactPhone, setContactPhone] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -91,22 +151,27 @@ export default function IndianRiverDirectPage() {
|
||||
]);
|
||||
|
||||
const brandData = brandResult.data as Brand | null;
|
||||
setBrand(brandData);
|
||||
dispatch({ type: "SET_BRAND", brand: brandData });
|
||||
|
||||
if (settingsResult.success && settingsResult.settings) {
|
||||
const s = settingsResult.settings;
|
||||
setLogoUrl(s.logo_url ?? null);
|
||||
setLogoUrlDark(s.logo_url_dark ?? null);
|
||||
setHeroTagline(s.hero_tagline ?? null);
|
||||
setCustomFooterText(s.custom_footer_text ?? null);
|
||||
setContactEmail(s.email ?? null);
|
||||
setContactPhone(s.phone ?? null);
|
||||
setShowSchedulePdf(s.show_schedule_pdf ?? true);
|
||||
setShowWholesaleLink(s.show_wholesale_link ?? true);
|
||||
dispatch({
|
||||
type: "SET_BRAND_SETTINGS",
|
||||
settings: {
|
||||
logoUrl: s.logo_url ?? null,
|
||||
logoUrlDark: s.logo_url_dark ?? null,
|
||||
heroTagline: s.hero_tagline ?? null,
|
||||
customFooterText: s.custom_footer_text ?? null,
|
||||
contactEmail: s.email ?? null,
|
||||
contactPhone: s.phone ?? null,
|
||||
showSchedulePdf: s.show_schedule_pdf ?? true,
|
||||
showWholesaleLink: s.show_wholesale_link ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { getCurrentAdminUser } = await import("@/actions/admin-user");
|
||||
setIsAdmin(!!await getCurrentAdminUser());
|
||||
dispatch({ type: "SET_IS_ADMIN", value: !!(await getCurrentAdminUser()) });
|
||||
} catch { /* not logged in */ }
|
||||
|
||||
if (brandData?.id) {
|
||||
@@ -114,8 +179,8 @@ export default function IndianRiverDirectPage() {
|
||||
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
|
||||
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
|
||||
]);
|
||||
setStops(stopsData ?? []);
|
||||
setProducts(productsData ?? []);
|
||||
dispatch({ type: "SET_STOPS", stops: stopsData ?? [] });
|
||||
dispatch({ type: "SET_PRODUCTS", products: productsData ?? [] });
|
||||
}
|
||||
}
|
||||
load();
|
||||
@@ -124,387 +189,422 @@ export default function IndianRiverDirectPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white/50 backdrop-blur-xl">
|
||||
<StorefrontHeader
|
||||
brandName={brand?.name ?? "Indian River Direct"}
|
||||
brandName={state.brand?.name ?? "Indian River Direct"}
|
||||
brandSlug="indian-river-direct"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
showWholesaleLink={showWholesaleLink}
|
||||
isAdmin={isAdmin}
|
||||
logoUrl={state.logoUrl}
|
||||
logoUrlDark={state.logoUrlDark}
|
||||
showWholesaleLink={state.showWholesaleLink}
|
||||
isAdmin={state.isAdmin}
|
||||
brandAccent="blue"
|
||||
/>
|
||||
|
||||
<main>
|
||||
{/* ── Hero — Light Fresh Background ── */}
|
||||
<section className="relative bg-gradient-to-b from-blue-50 via-white to-stone-50 overflow-hidden min-h-[640px] flex items-center">
|
||||
{/* Subtle decorative elements */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-100/40 rounded-full -translate-y-1/2 translate-x-1/4 blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-80 h-80 bg-blue-50/60 rounded-full translate-y-1/2 -translate-x-1/4 blur-3xl" />
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative z-10 mx-auto max-w-4xl text-center pt-20 pb-16">
|
||||
{/* Eyebrow */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-xs font-bold uppercase tracking-[0.3em] text-blue-600 mb-5"
|
||||
>
|
||||
Since 1985
|
||||
</motion.p>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-5xl md:text-6xl lg:text-7xl font-black tracking-tight text-stone-950 leading-[1.05]"
|
||||
>
|
||||
Fresh From<br />The Grove
|
||||
</motion.h1>
|
||||
|
||||
{/* Subheading */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.35 }}
|
||||
className="mt-8 text-xl md:text-2xl text-stone-600 leading-relaxed max-w-2xl mx-auto"
|
||||
>
|
||||
{heroTagline ?? "Family-owned and operated. Bringing the finest peaches, pecans, and seasonal citrus from our Florida groves directly to your neighborhood."}
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
className="mt-12 flex gap-4 justify-center flex-wrap"
|
||||
>
|
||||
<Link
|
||||
href="/indian-river-direct#stops"
|
||||
className="rounded-2xl bg-blue-600 px-8 py-4 font-bold text-white hover:bg-blue-500 active:bg-blue-700 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-blue-900/20 transition-all text-sm tracking-wider"
|
||||
>
|
||||
Find a Stop
|
||||
</Link>
|
||||
<Link
|
||||
href="/indian-river-direct#products"
|
||||
className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all text-sm tracking-wider border border-blue-200"
|
||||
>
|
||||
Shop Products
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.2, duration: 0.6 }}
|
||||
className="absolute bottom-8 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="flex flex-col items-center gap-2"
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-widest text-stone-400">Scroll</span>
|
||||
<svg className="w-5 h-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ── Why Indian River Direct Section ── */}
|
||||
<HeroSection heroTagline={state.heroTagline} />
|
||||
<WhyIndianRiverDirect />
|
||||
|
||||
{/* ── Pickup-Only Notice ── */}
|
||||
<FadeInSection className="py-16 bg-white relative">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="rounded-3xl bg-gradient-to-br from-blue-50/80 to-stone-50/80 backdrop-blur-sm border border-blue-100/50 p-8 shadow-xl shadow-blue-200/30">
|
||||
<div className="flex items-center justify-center gap-5">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-600/30">
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-stone-950">Pickup Only — Direct from the Truck</h3>
|
||||
<p className="text-stone-600 text-sm mt-1">All products picked up at our scheduled truck stops across Ohio, Indiana, Kentucky, West Virginia, Wisconsin, and Illinois.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
|
||||
{/* ── Schedule Downloads ── */}
|
||||
{showSchedulePdf && (
|
||||
<FadeInSection className="py-20 bg-gradient-to-b from-blue-50/50 to-stone-50/50 relative">
|
||||
<LayoutContainer>
|
||||
<div className="mb-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">2026 Season</p>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">Pickup Schedule</h2>
|
||||
<div className="mt-4 h-px w-12 bg-blue-600" />
|
||||
<p className="mt-4 text-stone-600">Download our schedule to find stops in your area.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 max-w-2xl">
|
||||
{SCHEDULE_PDFS.map((pdf) => (
|
||||
<a key={pdf.region}
|
||||
href={pdf.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-2xl bg-white border-2 border-stone-200 p-6 flex items-center gap-4 group shadow-lg hover:shadow-xl hover:border-blue-200 hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center shadow-lg shadow-blue-600/30">
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-stone-950 text-lg">{pdf.region}</h3>
|
||||
<p className="text-stone-500 text-sm">2026 Season Schedule</p>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-blue-500 group-hover:text-blue-700 group-hover:translate-x-1 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
)}
|
||||
|
||||
{/* ── Upcoming Stops ── */}
|
||||
<FadeInSection className="py-20 bg-white relative">
|
||||
<LayoutContainer>
|
||||
<div className="mb-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Family Farms</p>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">Upcoming Stops</h2>
|
||||
<div className="mt-4 h-px w-12 bg-blue-600" />
|
||||
<p className="mt-4 text-stone-600">Find a pickup location near you.</p>
|
||||
</div>
|
||||
|
||||
{stops.length === 0 ? (
|
||||
<div className="rounded-3xl bg-stone-50 border-2 border-stone-200 p-12 text-center">
|
||||
<p className="text-stone-500">No upcoming stops scheduled. Check back soon for the 2026 season!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{stops.slice(0, 6).map((stop) => (
|
||||
<div key={stop.id} className="rounded-2xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-5 shadow-lg hover:shadow-xl hover:border-blue-200 hover:-translate-y-1 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-stone-950 text-lg">{stop.city}, {stop.state}</h3>
|
||||
<p className="text-stone-500 text-sm">{stop.location}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-blue-600 px-3 py-1 text-xs font-bold text-white shadow-sm">
|
||||
Pickup
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-stone-600">
|
||||
<p className="font-semibold">{stop.date}</p>
|
||||
<p>{stop.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{stops.length > 6 && (
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
href="/indian-river-direct/stops"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
View all stops
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
|
||||
{/* ── Products Section ── */}
|
||||
<FadeInSection className="py-20 bg-stone-100 relative">
|
||||
<LayoutContainer>
|
||||
<div className="mb-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Fresh Products</p>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">From Our Grove to You</h2>
|
||||
<div className="mt-4 h-px w-12 bg-blue-600" />
|
||||
<p className="mt-4 max-w-2xl text-lg text-stone-600 leading-relaxed">
|
||||
Hand-picked at peak ripeness, delivered fresh from Titan Farms (South Carolina peaches) and Ellis Brothers (Georgia pecans) directly to your community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="rounded-3xl bg-white border-2 border-stone-200 p-12 text-center shadow-lg">
|
||||
<p className="text-stone-500">Products coming soon for the 2026 season!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden shadow-xl hover:shadow-2xl hover:-translate-y-2 hover:border-blue-200 transition-all duration-300 group">
|
||||
{/* Product Image */}
|
||||
<div className="aspect-[4/3] bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center relative overflow-hidden">
|
||||
{product.image_url ? (
|
||||
<Image src={product.image_url} alt={product.name} fill className="object-cover transition-transform duration-500 group-hover:scale-105" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" />
|
||||
) : (
|
||||
<div className="text-6xl opacity-30 transition-transform duration-500 group-hover:scale-110">🍑</div>
|
||||
)}
|
||||
{/* Badges */}
|
||||
<div className="absolute top-3 left-3 flex flex-wrap gap-2">
|
||||
{product.preorder && (
|
||||
<span className="rounded-full bg-white text-blue-700 px-3 py-1 text-xs font-bold shadow-lg">
|
||||
Pre-Order
|
||||
</span>
|
||||
)}
|
||||
{product.seasonal && (
|
||||
<span className="rounded-full bg-stone-900/80 backdrop-blur-sm px-3 py-1 text-xs font-medium text-white">
|
||||
{product.season_start} - {product.season_end}
|
||||
</span>
|
||||
)}
|
||||
{product.pickup_only && (
|
||||
<span className="rounded-full bg-blue-600 text-white px-3 py-1 text-xs font-medium shadow-md">
|
||||
Pickup Only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-bold text-stone-950 mb-2">{product.name}</h3>
|
||||
<p className="text-stone-600 text-sm leading-relaxed line-clamp-2 mb-4">
|
||||
{product.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold text-stone-950">
|
||||
{product.price_tba ? "Price TBA" : `$${product.price.toFixed(2)}`}
|
||||
</span>
|
||||
{product.price > 0 && (
|
||||
<button type="button" className="rounded-xl bg-blue-600 hover:bg-blue-500 active:bg-blue-700 px-5 py-2.5 text-sm font-bold text-white transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0">
|
||||
{product.preorder ? "Pre-Order" : "Add to Cart"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
|
||||
{/* ── Testimonials ── */}
|
||||
<FadeInSection className="py-20 bg-gradient-to-b from-blue-50 to-blue-100/50 relative">
|
||||
<LayoutContainer>
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-3">
|
||||
<span className="h-px w-12 bg-gradient-to-r from-transparent to-blue-600" />
|
||||
<div className="flex">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<svg key={i} className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<span className="h-px w-12 bg-gradient-to-l from-transparent to-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-black text-stone-950 tracking-tight">What Our Customers Say</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{TESTIMONIALS.map((t) => (
|
||||
<div key={t.name} className="rounded-2xl bg-white border-2 border-stone-200 p-6 shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all duration-300">
|
||||
<div className="flex mb-4">
|
||||
{[...Array(t.rating)].map((_, j) => (
|
||||
<svg key={j} className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-stone-600 text-sm leading-relaxed mb-4">
|
||||
"{t.text}"
|
||||
</blockquote>
|
||||
<p className="text-stone-950 font-bold text-sm">— {t.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
|
||||
{/* ── Newsletter ── */}
|
||||
<FadeInSection className="py-20 bg-stone-100 relative">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<div className="rounded-3xl bg-gradient-to-br from-blue-600 to-blue-700 p-10 shadow-2xl shadow-blue-900/30 relative overflow-hidden">
|
||||
{/* Decorative pattern */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
|
||||
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<h2 className="text-2xl md:text-3xl font-black text-white">Stay in the Loop</h2>
|
||||
<p className="mt-2 text-blue-100">Get the annual schedules and tour updates delivered to your inbox.</p>
|
||||
<form className="flex gap-3 max-w-md mx-auto mt-6">
|
||||
<input aria-label="Your@email.com"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 rounded-xl border-2 border-white/30 bg-white/90 backdrop-blur-sm px-4 py-3 text-stone-900 placeholder:text-stone-400 outline-none focus:border-white focus:ring-2 focus:ring-white/50 transition-all"
|
||||
/>
|
||||
<button type="button" className="rounded-xl bg-white text-blue-700 hover:bg-blue-50 px-6 py-3 font-bold transition-all shadow-lg hover:shadow-xl whitespace-nowrap">
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
|
||||
{/* ── CTA ── */}
|
||||
<section className="py-20 bg-gradient-to-br from-blue-600 via-blue-700 to-blue-800 relative overflow-hidden">
|
||||
{/* Decorative glow */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.1),transparent_50%)]" />
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-black text-white">Ready to order?</h2>
|
||||
<p className="mt-2 text-blue-100">Find a stop near you or browse our seasonal products.</p>
|
||||
<div className="mt-8 flex gap-4 justify-center flex-wrap">
|
||||
<Link href="/indian-river-direct#stops" className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
|
||||
Find a Stop
|
||||
</Link>
|
||||
<Link href="/indian-river-direct/about" className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 px-8 py-4 font-bold text-white hover:bg-blue-600/80 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
|
||||
Our Story
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
<PickupOnlyNotice />
|
||||
{state.showSchedulePdf && <ScheduleDownloads />}
|
||||
<UpcomingStops stops={state.stops} />
|
||||
<ProductsSection products={state.products} />
|
||||
<Testimonials />
|
||||
<Newsletter />
|
||||
<CTASection />
|
||||
</main>
|
||||
|
||||
<StorefrontFooter
|
||||
brandName={brand?.name ?? "Indian River Direct"}
|
||||
brandName={state.brand?.name ?? "Indian River Direct"}
|
||||
brandSlug="indian-river-direct"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
customFooterText={customFooterText}
|
||||
contactEmail={contactEmail}
|
||||
contactPhone={contactPhone}
|
||||
isAdmin={isAdmin}
|
||||
logoUrl={state.logoUrl}
|
||||
logoUrlDark={state.logoUrlDark}
|
||||
customFooterText={state.customFooterText}
|
||||
contactEmail={state.contactEmail}
|
||||
contactPhone={state.contactPhone}
|
||||
isAdmin={state.isAdmin}
|
||||
brandAccent="blue"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroSection({ heroTagline }: { heroTagline: string | null }) {
|
||||
return (
|
||||
<section className="relative bg-gradient-to-b from-blue-50 via-white to-stone-50 overflow-hidden min-h-[640px] flex items-center">
|
||||
{/* Subtle decorative elements */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-100/40 rounded-full -translate-y-1/2 translate-x-1/4 blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-80 h-80 bg-blue-50/60 rounded-full translate-y-1/2 -translate-x-1/4 blur-3xl" />
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative z-10 mx-auto max-w-4xl text-center pt-20 pb-16">
|
||||
{/* Eyebrow */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-xs font-bold uppercase tracking-[0.3em] text-blue-600 mb-5"
|
||||
>
|
||||
Since 1985
|
||||
</motion.p>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-5xl md:text-6xl lg:text-7xl font-black tracking-tight text-stone-950 leading-[1.05]"
|
||||
>
|
||||
Fresh From<br />The Grove
|
||||
</motion.h1>
|
||||
|
||||
{/* Subheading */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.35 }}
|
||||
className="mt-8 text-xl md:text-2xl text-stone-600 leading-relaxed max-w-2xl mx-auto"
|
||||
>
|
||||
{heroTagline ?? "Family-owned and operated. Bringing the finest peaches, pecans, and seasonal citrus from our Florida groves directly to your neighborhood."}
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
className="mt-12 flex gap-4 justify-center flex-wrap"
|
||||
>
|
||||
<Link
|
||||
href="/indian-river-direct#stops"
|
||||
className="rounded-2xl bg-blue-600 px-8 py-4 font-bold text-white hover:bg-blue-500 active:bg-blue-700 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-blue-900/20 transition-all text-sm tracking-wider"
|
||||
>
|
||||
Find a Stop
|
||||
</Link>
|
||||
<Link
|
||||
href="/indian-river-direct#products"
|
||||
className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all text-sm tracking-wider border border-blue-200"
|
||||
>
|
||||
Shop Products
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.2, duration: 0.6 }}
|
||||
className="absolute bottom-8 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="flex flex-col items-center gap-2"
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-widest text-stone-400">Scroll</span>
|
||||
<svg className="w-5 h-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PickupOnlyNotice() {
|
||||
return (
|
||||
<FadeInSection className="py-16 bg-white relative">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="rounded-3xl bg-gradient-to-br from-blue-50/80 to-stone-50/80 backdrop-blur-sm border border-blue-100/50 p-8 shadow-xl shadow-blue-200/30">
|
||||
<div className="flex items-center justify-center gap-5">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-600/30">
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-stone-950">Pickup Only — Direct from the Truck</h3>
|
||||
<p className="text-stone-600 text-sm mt-1">All products picked up at our scheduled truck stops across Ohio, Indiana, Kentucky, West Virginia, Wisconsin, and Illinois.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleDownloads() {
|
||||
return (
|
||||
<FadeInSection className="py-20 bg-gradient-to-b from-blue-50/50 to-stone-50/50 relative">
|
||||
<LayoutContainer>
|
||||
<div className="mb-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">2026 Season</p>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">Pickup Schedule</h2>
|
||||
<div className="mt-4 h-px w-12 bg-blue-600" />
|
||||
<p className="mt-4 text-stone-600">Download our schedule to find stops in your area.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 max-w-2xl">
|
||||
{SCHEDULE_PDFS.map((pdf) => (
|
||||
<a key={pdf.region}
|
||||
href={pdf.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-2xl bg-white border-2 border-stone-200 p-6 flex items-center gap-4 group shadow-lg hover:shadow-xl hover:border-blue-200 hover:-translate-y-1 transition-all duration-300"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center shadow-lg shadow-blue-600/30">
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-stone-950 text-lg">{pdf.region}</h3>
|
||||
<p className="text-stone-500 text-sm">2026 Season Schedule</p>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-blue-500 group-hover:text-blue-700 group-hover:translate-x-1 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
);
|
||||
}
|
||||
|
||||
function UpcomingStops({ stops }: { stops: Stop[] }) {
|
||||
return (
|
||||
<FadeInSection className="py-20 bg-white relative">
|
||||
<LayoutContainer>
|
||||
<div className="mb-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Family Farms</p>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">Upcoming Stops</h2>
|
||||
<div className="mt-4 h-px w-12 bg-blue-600" />
|
||||
<p className="mt-4 text-stone-600">Find a pickup location near you.</p>
|
||||
</div>
|
||||
|
||||
{stops.length === 0 ? (
|
||||
<div className="rounded-3xl bg-stone-50 border-2 border-stone-200 p-12 text-center">
|
||||
<p className="text-stone-500">No upcoming stops scheduled. Check back soon for the 2026 season!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{stops.slice(0, 6).map((stop) => (
|
||||
<div key={stop.id} className="rounded-2xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-5 shadow-lg hover:shadow-xl hover:border-blue-200 hover:-translate-y-1 transition-all duration-300">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-stone-950 text-lg">{stop.city}, {stop.state}</h3>
|
||||
<p className="text-stone-500 text-sm">{stop.location}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-blue-600 px-3 py-1 text-xs font-bold text-white shadow-sm">
|
||||
Pickup
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-stone-600">
|
||||
<p className="font-semibold">{stop.date}</p>
|
||||
<p>{stop.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{stops.length > 6 && (
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
href="/indian-river-direct/stops"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
View all stops
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsSection({ products }: { products: Product[] }) {
|
||||
return (
|
||||
<FadeInSection className="py-20 bg-stone-100 relative">
|
||||
<LayoutContainer>
|
||||
<div className="mb-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Fresh Products</p>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">From Our Grove to You</h2>
|
||||
<div className="mt-4 h-px w-12 bg-blue-600" />
|
||||
<p className="mt-4 max-w-2xl text-lg text-stone-600 leading-relaxed">
|
||||
Hand-picked at peak ripeness, delivered fresh from Titan Farms (South Carolina peaches) and Ellis Brothers (Georgia pecans) directly to your community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="rounded-3xl bg-white border-2 border-stone-200 p-12 text-center shadow-lg">
|
||||
<p className="text-stone-500">Products coming soon for the 2026 season!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductCard({ product }: { product: Product }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden shadow-xl hover:shadow-2xl hover:-translate-y-2 hover:border-blue-200 transition-all duration-300 group">
|
||||
{/* Product Image */}
|
||||
<div className="aspect-[4/3] bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center relative overflow-hidden">
|
||||
{product.image_url ? (
|
||||
<Image src={product.image_url} alt={product.name} fill className="object-cover transition-transform duration-500 group-hover:scale-105" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" />
|
||||
) : (
|
||||
<div className="text-6xl opacity-30 transition-transform duration-500 group-hover:scale-110">🍑</div>
|
||||
)}
|
||||
{/* Badges */}
|
||||
<div className="absolute top-3 left-3 flex flex-wrap gap-2">
|
||||
{product.preorder && (
|
||||
<span className="rounded-full bg-white text-blue-700 px-3 py-1 text-xs font-bold shadow-lg">
|
||||
Pre-Order
|
||||
</span>
|
||||
)}
|
||||
{product.seasonal && (
|
||||
<span className="rounded-full bg-stone-900/80 backdrop-blur-sm px-3 py-1 text-xs font-medium text-white">
|
||||
{product.season_start} - {product.season_end}
|
||||
</span>
|
||||
)}
|
||||
{product.pickup_only && (
|
||||
<span className="rounded-full bg-blue-600 text-white px-3 py-1 text-xs font-medium shadow-md">
|
||||
Pickup Only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-bold text-stone-950 mb-2">{product.name}</h3>
|
||||
<p className="text-stone-600 text-sm leading-relaxed line-clamp-2 mb-4">
|
||||
{product.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold text-stone-950">
|
||||
{product.price_tba ? "Price TBA" : `$${product.price.toFixed(2)}`}
|
||||
</span>
|
||||
{product.price > 0 && (
|
||||
<button type="button" className="rounded-xl bg-blue-600 hover:bg-blue-500 active:bg-blue-700 px-5 py-2.5 text-sm font-bold text-white transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0">
|
||||
{product.preorder ? "Pre-Order" : "Add to Cart"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Testimonials() {
|
||||
return (
|
||||
<FadeInSection className="py-20 bg-gradient-to-b from-blue-50 to-blue-100/50 relative">
|
||||
<LayoutContainer>
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-3">
|
||||
<span className="h-px w-12 bg-gradient-to-r from-transparent to-blue-600" />
|
||||
<div className="flex">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<svg key={i} className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<span className="h-px w-12 bg-gradient-to-l from-transparent to-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-black text-stone-950 tracking-tight">What Our Customers Say</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{TESTIMONIALS.map((t) => (
|
||||
<div key={t.name} className="rounded-2xl bg-white border-2 border-stone-200 p-6 shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all duration-300">
|
||||
<div className="flex mb-4">
|
||||
{[...Array(t.rating)].map((_, j) => (
|
||||
<svg key={j} className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="text-stone-600 text-sm leading-relaxed mb-4">
|
||||
"{t.text}"
|
||||
</blockquote>
|
||||
<p className="text-stone-950 font-bold text-sm">— {t.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
);
|
||||
}
|
||||
|
||||
function Newsletter() {
|
||||
return (
|
||||
<FadeInSection className="py-20 bg-stone-100 relative">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<div className="rounded-3xl bg-gradient-to-br from-blue-600 to-blue-700 p-10 shadow-2xl shadow-blue-900/30 relative overflow-hidden">
|
||||
{/* Decorative pattern */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
|
||||
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<h2 className="text-2xl md:text-3xl font-black text-white">Stay in the Loop</h2>
|
||||
<p className="mt-2 text-blue-100">Get the annual schedules and tour updates delivered to your inbox.</p>
|
||||
<form className="flex gap-3 max-w-md mx-auto mt-6">
|
||||
<input aria-label="Your@email.com"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 rounded-xl border-2 border-white/30 bg-white/90 backdrop-blur-sm px-4 py-3 text-stone-900 placeholder:text-stone-400 outline-none focus:border-white focus:ring-2 focus:ring-white/50 transition-all"
|
||||
/>
|
||||
<button type="button" className="rounded-xl bg-white text-blue-700 hover:bg-blue-50 px-6 py-3 font-bold transition-all shadow-lg hover:shadow-xl whitespace-nowrap">
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</FadeInSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CTASection() {
|
||||
return (
|
||||
<section className="py-20 bg-gradient-to-br from-blue-600 via-blue-700 to-blue-800 relative overflow-hidden">
|
||||
{/* Decorative glow */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.1),transparent_50%)]" />
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-black text-white">Ready to order?</h2>
|
||||
<p className="mt-2 text-blue-100">Find a stop near you or browse our seasonal products.</p>
|
||||
<div className="mt-8 flex gap-4 justify-center flex-wrap">
|
||||
<Link href="/indian-river-direct#stops" className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
|
||||
Find a Stop
|
||||
</Link>
|
||||
<Link href="/indian-river-direct/about" className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 px-8 py-4 font-bold text-white hover:bg-blue-600/80 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
|
||||
Our Story
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState, useId, useTransition } from "react";
|
||||
import { useCallback, useReducer, useId, useTransition } from "react";
|
||||
import { devLoginAction } from "@/actions/auth-actions";
|
||||
|
||||
type LoginClientProps = {
|
||||
@@ -13,66 +13,105 @@ const REDIRECT_URL = "/admin";
|
||||
// Development mode detection
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
type State = {
|
||||
email: string;
|
||||
password: string;
|
||||
loading: boolean;
|
||||
googleLoading: boolean;
|
||||
localError: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_EMAIL"; value: string }
|
||||
| { type: "SET_PASSWORD"; value: string }
|
||||
| { type: "SET_LOADING"; value: boolean }
|
||||
| { type: "SET_GOOGLE_LOADING"; value: boolean }
|
||||
| { type: "SET_LOCAL_ERROR"; value: string | null }
|
||||
| { type: "RESET_FOR_RETRY" };
|
||||
|
||||
const initialState: State = {
|
||||
email: "",
|
||||
password: "",
|
||||
loading: false,
|
||||
googleLoading: false,
|
||||
localError: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_EMAIL":
|
||||
return { ...state, email: action.value };
|
||||
case "SET_PASSWORD":
|
||||
return { ...state, password: action.value };
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.value };
|
||||
case "SET_GOOGLE_LOADING":
|
||||
return { ...state, googleLoading: action.value };
|
||||
case "SET_LOCAL_ERROR":
|
||||
return { ...state, localError: action.value };
|
||||
case "RESET_FOR_RETRY":
|
||||
return { ...state, localError: null };
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginClient({ error }: LoginClientProps) {
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [, startDevLoginTransition] = useTransition();
|
||||
|
||||
async function handleSignIn(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
setLocalError("Email and password are required.");
|
||||
if (!state.email || !state.password) {
|
||||
dispatch({ type: "SET_LOCAL_ERROR", value: "Email and password are required." });
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setLoading(true);
|
||||
dispatch({ type: "SET_LOCAL_ERROR", value: null });
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/sign-in", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email.trim().toLowerCase(), password: password.trim() }),
|
||||
body: JSON.stringify({ email: state.email.trim().toLowerCase(), password: state.password.trim() }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) {
|
||||
setLocalError(data.message ?? "Sign-in failed. Please check your email and password.");
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_LOCAL_ERROR", value: data.message ?? "Sign-in failed. Please check your email and password." });
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cookie is set server-side via next/headers — just redirect on success.
|
||||
window.location.href = REDIRECT_URL;
|
||||
} catch {
|
||||
setLocalError("Sign-in failed. Please try again.");
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_LOCAL_ERROR", value: "Sign-in failed. Please try again." });
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
setGoogleLoading(true);
|
||||
setLocalError(null);
|
||||
dispatch({ type: "SET_GOOGLE_LOADING", value: true });
|
||||
dispatch({ type: "SET_LOCAL_ERROR", value: null });
|
||||
try {
|
||||
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
|
||||
const result = await signInWithGoogleAction({ callbackURL: REDIRECT_URL });
|
||||
if (result.error || !result.url) {
|
||||
setLocalError(
|
||||
result.error ??
|
||||
dispatch({
|
||||
type: "SET_LOCAL_ERROR",
|
||||
value:
|
||||
result.error ??
|
||||
"Google sign-in is not available. Contact your administrator or sign in with email + password.",
|
||||
);
|
||||
setGoogleLoading(false);
|
||||
});
|
||||
dispatch({ type: "SET_GOOGLE_LOADING", value: false });
|
||||
return;
|
||||
}
|
||||
window.location.href = result.url;
|
||||
} catch {
|
||||
setLocalError("Google sign-in failed. Please try again.");
|
||||
setGoogleLoading(false);
|
||||
dispatch({ type: "SET_LOCAL_ERROR", value: "Google sign-in failed. Please try again." });
|
||||
dispatch({ type: "SET_GOOGLE_LOADING", value: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +125,7 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const displayError = error ?? localError;
|
||||
const displayError = error ?? state.localError;
|
||||
|
||||
return (
|
||||
<main className="atelier-canvas relative min-h-screen flex flex-col overflow-hidden">
|
||||
@@ -150,11 +189,11 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={googleLoading || loading}
|
||||
disabled={state.googleLoading || state.loading}
|
||||
className="atelier-cta atelier-cta-secondary w-full"
|
||||
style={{ background: "white", color: "#1c1917", border: "1px solid #e7e5e4", boxShadow: "0 1px 2px rgba(0,0,0,0.04)" }}
|
||||
>
|
||||
{googleLoading ? (
|
||||
{state.googleLoading ? (
|
||||
<span className="relative z-10 inline-flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
@@ -195,9 +234,9 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
autoComplete="username"
|
||||
className="atelier-input"
|
||||
placeholder="you@yourfarm.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
value={state.email}
|
||||
onChange={(e) => dispatch({ type: "SET_EMAIL", value: e.target.value })}
|
||||
disabled={state.loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -216,9 +255,9 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
autoComplete="current-password"
|
||||
className="atelier-input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
value={state.password}
|
||||
onChange={(e) => dispatch({ type: "SET_PASSWORD", value: e.target.value })}
|
||||
disabled={state.loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -236,11 +275,11 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={state.loading}
|
||||
className="atelier-cta w-full"
|
||||
>
|
||||
<span className="relative z-10">{loading ? "Signing in…" : "Sign in"}</span>
|
||||
{!loading && (
|
||||
<span className="relative z-10">{state.loading ? "Signing in…" : "Sign in"}</span>
|
||||
{!state.loading && (
|
||||
<svg className="h-4 w-4 relative z-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
|
||||
@@ -419,9 +419,9 @@ function Testimonials() {
|
||||
</h2>
|
||||
<p className="text-slate-500">
|
||||
We're in early access with produce brands across the country. Customer stories coming soon —
|
||||
<a href="/contact" className="text-emerald-600 hover:underline font-medium">
|
||||
<Link href="/contact" className="text-emerald-600 hover:underline font-medium">
|
||||
talk to us
|
||||
</a>
|
||||
</Link>
|
||||
if you'd like to be a reference customer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type State = {
|
||||
password: string;
|
||||
confirm: string;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_PASSWORD"; value: string }
|
||||
| { type: "SET_CONFIRM"; value: string }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SET_LOADING"; value: boolean }
|
||||
| { type: "SET_DONE"; value: boolean };
|
||||
|
||||
const initialState: State = {
|
||||
password: "",
|
||||
confirm: "",
|
||||
error: null,
|
||||
loading: false,
|
||||
done: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_PASSWORD":
|
||||
return { ...state, password: action.value };
|
||||
case "SET_CONFIRM":
|
||||
return { ...state, confirm: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.value };
|
||||
case "SET_DONE":
|
||||
return { ...state, done: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters.");
|
||||
if (state.password.length < 8) {
|
||||
dispatch({ type: "SET_ERROR", value: "Password must be at least 8 characters." });
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
if (state.password !== state.confirm) {
|
||||
dispatch({ type: "SET_ERROR", value: "Passwords do not match." });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({ password: state.password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.error) {
|
||||
setError(data.message ?? "Failed to reset password. The link may have expired.");
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_ERROR", value: data.message ?? "Failed to reset password. The link may have expired." });
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setDone(true);
|
||||
dispatch({ type: "SET_DONE", value: true });
|
||||
} catch {
|
||||
setError("An unexpected error occurred. Please try again.");
|
||||
dispatch({ type: "SET_ERROR", value: "An unexpected error occurred. Please try again." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
if (state.done) {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-md">
|
||||
@@ -89,9 +123,9 @@ export default function ResetPasswordPage() {
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
{error && (
|
||||
{state.error && (
|
||||
<div className="rounded-xl bg-red-50 p-4 text-sm text-red-700">
|
||||
{error}
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,8 +133,8 @@ export default function ResetPasswordPage() {
|
||||
<label htmlFor="fld-1-new-password" className="block text-sm font-medium text-slate-700">New Password</label>
|
||||
<input id="fld-1-new-password" aria-label=". 8 Characters"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={state.password}
|
||||
onChange={(e) => dispatch({ type: "SET_PASSWORD", value: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
|
||||
@@ -112,8 +146,8 @@ export default function ResetPasswordPage() {
|
||||
<label htmlFor="fld-2-confirm-password" className="block text-sm font-medium text-slate-700">Confirm Password</label>
|
||||
<input id="fld-2-confirm-password" aria-label="Repeat Password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
value={state.confirm}
|
||||
onChange={(e) => dispatch({ type: "SET_CONFIRM", value: e.target.value })}
|
||||
required
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
|
||||
placeholder="Repeat password"
|
||||
@@ -122,10 +156,10 @@ export default function ResetPasswordPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={state.loading}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
{state.loading ? "Updating..." : "Update Password"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+443
-343
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useReducer, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { m as motion, useInView } from "framer-motion";
|
||||
import TuxedoVideoHero from "@/components/storefront/TuxedoVideoHero";
|
||||
@@ -61,6 +61,102 @@ type Product = {
|
||||
pickup_type?: "scheduled_stop" | "shed";
|
||||
};
|
||||
|
||||
type State = {
|
||||
brand: Brand | null;
|
||||
stops: Stop[];
|
||||
products: Product[];
|
||||
wholesaleEnabled: boolean;
|
||||
logoUrl: string | null;
|
||||
logoUrlDark: string | null;
|
||||
olatheSweetLogoUrl: string | null;
|
||||
olatheSweetLogoUrlDark: string | null;
|
||||
showSchedulePdf: boolean;
|
||||
showWholesaleLink: boolean;
|
||||
heroTagline: string | null;
|
||||
isAdmin: boolean;
|
||||
customFooterText: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
brandPrimaryColor: string | null;
|
||||
brandBgColor: string | null;
|
||||
brandTextColor: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_BRAND"; brand: Brand | null }
|
||||
| { type: "SET_STOPS"; stops: Stop[] }
|
||||
| { type: "SET_PRODUCTS"; products: Product[] }
|
||||
| {
|
||||
type: "SET_SETTINGS";
|
||||
wholesaleEnabled: boolean;
|
||||
logoUrl: string | null;
|
||||
logoUrlDark: string | null;
|
||||
olatheSweetLogoUrl: string | null;
|
||||
olatheSweetLogoUrlDark: string | null;
|
||||
showSchedulePdf: boolean;
|
||||
showWholesaleLink: boolean;
|
||||
heroTagline: string | null;
|
||||
customFooterText: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
brandPrimaryColor: string | null;
|
||||
brandBgColor: string | null;
|
||||
brandTextColor: string | null;
|
||||
}
|
||||
| { type: "SET_IS_ADMIN"; isAdmin: boolean };
|
||||
|
||||
const initialState: State = {
|
||||
brand: null,
|
||||
stops: [],
|
||||
products: [],
|
||||
wholesaleEnabled: false,
|
||||
logoUrl: null,
|
||||
logoUrlDark: null,
|
||||
olatheSweetLogoUrl: null,
|
||||
olatheSweetLogoUrlDark: null,
|
||||
showSchedulePdf: true,
|
||||
showWholesaleLink: true,
|
||||
heroTagline: null,
|
||||
isAdmin: false,
|
||||
customFooterText: null,
|
||||
contactEmail: null,
|
||||
contactPhone: null,
|
||||
brandPrimaryColor: null,
|
||||
brandBgColor: null,
|
||||
brandTextColor: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_BRAND":
|
||||
return { ...state, brand: action.brand };
|
||||
case "SET_STOPS":
|
||||
return { ...state, stops: action.stops };
|
||||
case "SET_PRODUCTS":
|
||||
return { ...state, products: action.products };
|
||||
case "SET_SETTINGS":
|
||||
return {
|
||||
...state,
|
||||
wholesaleEnabled: action.wholesaleEnabled,
|
||||
logoUrl: action.logoUrl,
|
||||
logoUrlDark: action.logoUrlDark,
|
||||
olatheSweetLogoUrl: action.olatheSweetLogoUrl,
|
||||
olatheSweetLogoUrlDark: action.olatheSweetLogoUrlDark,
|
||||
showSchedulePdf: action.showSchedulePdf,
|
||||
showWholesaleLink: action.showWholesaleLink,
|
||||
heroTagline: action.heroTagline,
|
||||
customFooterText: action.customFooterText,
|
||||
contactEmail: action.contactEmail,
|
||||
contactPhone: action.contactPhone,
|
||||
brandPrimaryColor: action.brandPrimaryColor,
|
||||
brandBgColor: action.brandBgColor,
|
||||
brandTextColor: action.brandTextColor,
|
||||
};
|
||||
case "SET_IS_ADMIN":
|
||||
return { ...state, isAdmin: action.isAdmin };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Why Choose Tuxedo Corn — editorial masonry redesign ─────────────
|
||||
type FeatureColor = "emerald" | "amber";
|
||||
|
||||
@@ -402,57 +498,314 @@ function WhyTuxedoCorn() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section header ──────────────────────────────────────────────────
|
||||
function SectionHeader({
|
||||
eyebrow,
|
||||
headline,
|
||||
subtext,
|
||||
accent = "emerald",
|
||||
}: {
|
||||
eyebrow: string;
|
||||
headline: string;
|
||||
subtext: string;
|
||||
accent?: "emerald" | "stone";
|
||||
}) {
|
||||
// ── Wholesale link banner ───────────────────────────────────────────
|
||||
function WholesaleBar({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="mb-10 sm:mb-14">
|
||||
<p className={`text-[10px] sm:text-[11px] font-semibold uppercase tracking-widest ${accent === "emerald" ? "text-emerald-600" : "text-stone-500"} mb-3 sm:mb-4`}>
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
|
||||
{headline}
|
||||
</h2>
|
||||
<div className="mt-5 sm:mt-6 h-px w-10 sm:w-12 bg-emerald-600" />
|
||||
<p className="mt-5 sm:mt-6 max-w-2xl text-base sm:text-lg text-stone-500 leading-relaxed">
|
||||
{subtext}
|
||||
</p>
|
||||
<div className="bg-stone-950 border-b border-zinc-800 py-3.5">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<Link
|
||||
href="/wholesale/login"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-emerald-700 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-600 active:bg-emerald-800 transition-all duration-200 hover:-translate-y-0.5"
|
||||
>
|
||||
Wholesale Portal →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stops section ───────────────────────────────────────────────────
|
||||
function StopsSection({ stops, showSchedulePdf }: { stops: Stop[]; showSchedulePdf: boolean }) {
|
||||
return (
|
||||
<section id="stops" className="relative bg-gradient-to-b from-stone-100 to-stone-50 scroll-mt-20">
|
||||
{/* Parallax background layers */}
|
||||
<ParallaxLayer speed={0.2} className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-[40%] h-full bg-gradient-to-l from-emerald-50/30 to-transparent" />
|
||||
</ParallaxLayer>
|
||||
|
||||
<div className="relative py-28">
|
||||
<LayoutContainer>
|
||||
<div className="mb-14 flex items-end justify-between">
|
||||
<div>
|
||||
<FadeOnScroll from="left">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Delivery Stops</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.1}>
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
|
||||
Upcoming<br className="hidden md:block" /> Stops
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.15}>
|
||||
<div className="mt-6 h-px w-12 bg-emerald-600" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.2}>
|
||||
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
|
||||
Find a nearby stop and preorder your corn. Pickup is easy and guaranteed.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
{showSchedulePdf && (
|
||||
<FadeOnScroll from="right" delay={0.3}>
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="shrink-0 hidden md:inline-flex items-center gap-2.5 rounded-2xl bg-stone-900 px-6 py-3.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-950 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Download Schedule
|
||||
</Link>
|
||||
</FadeOnScroll>
|
||||
)}
|
||||
</div>
|
||||
<FadeOnScroll from="up" delay={0.3}>
|
||||
{stops.length === 0 ? (
|
||||
<div className="rounded-3xl bg-white p-12 sm:p-16 text-center ring-1 ring-stone-200/60">
|
||||
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-stone-100">
|
||||
<svg className="h-7 w-7 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-stone-800">No stops on the calendar just yet</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-stone-500">
|
||||
The harvest is right around the corner — new pickup stops go live weekly. You can still preorder below and pick a stop once they're announced.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/tuxedo/stops"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-5 py-2.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
|
||||
>
|
||||
View All Stops
|
||||
</Link>
|
||||
{showSchedulePdf && (
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Download Schedule
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PaginatedStops stops={stops} brandSlug="tuxedo" brandAccent="green" />
|
||||
)}
|
||||
</FadeOnScroll>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section divider ─────────────────────────────────────────────────
|
||||
function SectionDivider() {
|
||||
return (
|
||||
<div className="relative h-24 bg-stone-50 overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="h-px w-24 bg-gradient-to-r from-transparent via-stone-300 to-transparent" />
|
||||
<svg className="h-6 w-6 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v13m0 0V8m0 13V4m0 9H4m8 0h8m-8-4h8" />
|
||||
</svg>
|
||||
<div className="h-px w-24 bg-gradient-to-r from-transparent via-stone-300 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Products section ────────────────────────────────────────────────
|
||||
function ProductsSection({
|
||||
products,
|
||||
brandName,
|
||||
olatheSweetLogoUrlDark,
|
||||
}: {
|
||||
products: Product[];
|
||||
brandName: string;
|
||||
olatheSweetLogoUrlDark: string | null;
|
||||
}) {
|
||||
return (
|
||||
<section id="products" className="relative bg-white">
|
||||
{/* Scroll-driven reveal for section header */}
|
||||
<div className="py-20 bg-gradient-to-b from-stone-50 to-white">
|
||||
<LayoutContainer>
|
||||
<ScrollReveal from="up" className="mb-14">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Farm-Direct</p>
|
||||
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
|
||||
This Week's<br className="hidden md:block" /> Harvest
|
||||
</h2>
|
||||
<div className="mt-6 h-px w-12 bg-emerald-600" />
|
||||
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
|
||||
Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
|
||||
{/* Cinematic showcase replaces static grid */}
|
||||
<CinematicShowcase
|
||||
products={products.slice(0, 3).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description ?? "",
|
||||
price: `$${p.price}`,
|
||||
type: p.type,
|
||||
imageUrl: p.image_url,
|
||||
brand_id: p.brand_id,
|
||||
brand_slug: "tuxedo",
|
||||
is_taxable: p.is_taxable,
|
||||
pickup_type: p.pickup_type,
|
||||
}))}
|
||||
brandSlug="tuxedo"
|
||||
brandName={brandName}
|
||||
/>
|
||||
|
||||
{/* Mobile-friendly fallback grid for smaller screens */}
|
||||
<div className="md:hidden py-16 bg-stone-50">
|
||||
<LayoutContainer>
|
||||
<div className="grid gap-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.slice(0, 6).map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
description={product.description}
|
||||
price={`$${product.price}`}
|
||||
type={product.type}
|
||||
imageUrl={product.image_url}
|
||||
brandSlug="tuxedo"
|
||||
brandName="Tuxedo Corn"
|
||||
brandId={product.brand_id}
|
||||
brandAccent="green"
|
||||
is_taxable={product.is_taxable}
|
||||
pickup_type={product.pickup_type}
|
||||
olatheSweetLogoUrlDark={olatheSweetLogoUrlDark}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
|
||||
{products.length > 3 && (
|
||||
<div className="mt-10 text-center pb-16">
|
||||
<p className="text-sm text-stone-400">{products.length - 3} more products available in the full catalog.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story section ───────────────────────────────────────────────────
|
||||
function StorySection() {
|
||||
return (
|
||||
<section id="story" className="py-32 bg-stone-950 relative overflow-hidden">
|
||||
{/* Parallax decorative elements */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<ParallaxLayer speed={0.3} className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-5" style={{
|
||||
background: "radial-gradient(circle, rgba(16,185,129,0.3) 0%, transparent 70%)",
|
||||
filter: "blur(8px)",
|
||||
}}>
|
||||
<div />
|
||||
</ParallaxLayer>
|
||||
<ParallaxLayer speed={0.5} className="absolute bottom-1/4 right-1/4 w-64 h-64 rounded-full opacity-5" style={{
|
||||
background: "radial-gradient(circle, rgba(255,215,0,0.3) 0%, transparent 70%)",
|
||||
filter: "blur(8px)",
|
||||
}}>
|
||||
<div />
|
||||
</ParallaxLayer>
|
||||
</div>
|
||||
|
||||
{/* Large background text for parallax depth */}
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden opacity-5 pointer-events-none">
|
||||
<ParallaxLayer speed={0.2}>
|
||||
<span className="text-[20vw] font-black text-white leading-none">SINCE</span>
|
||||
</ParallaxLayer>
|
||||
</div>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="text-center max-w-3xl mx-auto relative z-10">
|
||||
<FadeOnScroll from="up" className="mb-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-400/60 mb-4">Our Story</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.1}>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-white leading-[1.05] mb-8">
|
||||
Three Generations of<br className="hidden md:block" /> Sweet Corn Excellence
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.2}>
|
||||
<div className="mx-auto mt-8 mb-10 h-px w-16 bg-gradient-to-r from-emerald-600 to-amber-500" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.3}>
|
||||
<p className="text-stone-400 leading-relaxed text-lg md:text-xl max-w-2xl mx-auto">
|
||||
Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn — developed for Colorado's high-altitude mountain climate and grown by the same family for over 40 years.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
{/* Stats with counter animation */}
|
||||
<FadeOnScroll from="up" delay={0.4}>
|
||||
<div className="flex items-center justify-center gap-12 md:gap-16 mt-16 flex-wrap">
|
||||
{[
|
||||
{ stat: "40", suffix: "+", label: "Years Growing" },
|
||||
{ stat: "3", suffix: "", label: "Generations" },
|
||||
{ stat: "100", suffix: "%", label: "Hand-Picked" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div className="text-4xl md:text-5xl lg:text-6xl font-black text-white" style={{ textShadow: "0 4px 30px rgba(16,185,129,0.3)" }}>
|
||||
<span className="counter-animate" data-target={parseInt(item.stat, 10)}>0</span>
|
||||
<span style={{ color: "#10b981" }}>{item.suffix}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] uppercase tracking-[0.2em] text-stone-500">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.5}>
|
||||
<div className="mt-16">
|
||||
<Link
|
||||
href="/tuxedo/about"
|
||||
className="group inline-flex items-center gap-3 rounded-full px-10 py-4 text-sm font-bold transition-all duration-300"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
|
||||
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-3px)";
|
||||
e.currentTarget.style.boxShadow = "0 12px 40px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow = "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)";
|
||||
}}
|
||||
>
|
||||
<span>Read Our Story</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TuxedoPage() {
|
||||
const [brand, setBrand] = useState<Brand | null>(null);
|
||||
const [stops, setStops] = useState<Stop[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [wholesaleEnabled, setWholesaleEnabled] = useState(false);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
|
||||
const [olatheSweetLogoUrl, setOlatheSweetLogoUrl] = useState<string | null>(null);
|
||||
const [olatheSweetLogoUrlDark, setOlatheSweetLogoUrlDark] = useState<string | null>(null);
|
||||
const [showSchedulePdf, setShowSchedulePdf] = useState(true);
|
||||
const [showWholesaleLink, setShowWholesaleLink] = useState(true);
|
||||
const [heroTagline, setHeroTagline] = useState<string | null>(null);
|
||||
const heroImageUrlRef = useRef<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [customFooterText, setCustomFooterText] = useState<string | null>(null);
|
||||
const [contactEmail, setContactEmail] = useState<string | null>(null);
|
||||
const [contactPhone, setContactPhone] = useState<string | null>(null);
|
||||
const [brandPrimaryColor, setBrandPrimaryColor] = useState<string | null>(null);
|
||||
const [brandBgColor, setBrandBgColor] = useState<string | null>(null);
|
||||
const [brandTextColor, setBrandTextColor] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
// Scope ref for GSAP context (fixes "invalid scope"/missing targets by providing explicit scope; selectors now limited to page)
|
||||
const pageScopeRef = useRef<HTMLDivElement>(null);
|
||||
// hero image url is read once from settings and never re-rendered; keep as ref to preserve original behavior
|
||||
const heroImageUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -464,31 +817,34 @@ export default function TuxedoPage() {
|
||||
]);
|
||||
|
||||
const brandData = brandResult.data as Brand | null;
|
||||
setBrand(brandData);
|
||||
dispatch({ type: "SET_BRAND", brand: brandData });
|
||||
|
||||
if (settingsResult.success && settingsResult.settings) {
|
||||
const s = settingsResult.settings;
|
||||
setLogoUrl(s.logo_url ?? null);
|
||||
setLogoUrlDark(s.logo_url_dark ?? null);
|
||||
setOlatheSweetLogoUrl(s.olathe_sweet_logo_url ?? null);
|
||||
setOlatheSweetLogoUrlDark(s.olathe_sweet_logo_url_dark ?? null);
|
||||
heroImageUrlRef.current = s.hero_image_url ?? null;
|
||||
setHeroTagline(s.hero_tagline ?? null);
|
||||
setCustomFooterText(s.custom_footer_text ?? null);
|
||||
setContactEmail(s.email ?? null);
|
||||
setContactPhone(s.phone ?? null);
|
||||
setShowSchedulePdf(s.show_schedule_pdf ?? true);
|
||||
setShowWholesaleLink(s.show_wholesale_link ?? true);
|
||||
setWholesaleEnabled(settingsResult.wholesaleEnabled ?? false);
|
||||
setBrandPrimaryColor(s.brand_primary_color ?? null);
|
||||
setBrandBgColor(s.brand_bg_color ?? null);
|
||||
setBrandTextColor(s.brand_text_color ?? null);
|
||||
dispatch({
|
||||
type: "SET_SETTINGS",
|
||||
wholesaleEnabled: settingsResult.wholesaleEnabled ?? false,
|
||||
logoUrl: s.logo_url ?? null,
|
||||
logoUrlDark: s.logo_url_dark ?? null,
|
||||
olatheSweetLogoUrl: s.olathe_sweet_logo_url ?? null,
|
||||
olatheSweetLogoUrlDark: s.olathe_sweet_logo_url_dark ?? null,
|
||||
showSchedulePdf: s.show_schedule_pdf ?? true,
|
||||
showWholesaleLink: s.show_wholesale_link ?? true,
|
||||
heroTagline: s.hero_tagline ?? null,
|
||||
customFooterText: s.custom_footer_text ?? null,
|
||||
contactEmail: s.email ?? null,
|
||||
contactPhone: s.phone ?? null,
|
||||
brandPrimaryColor: s.brand_primary_color ?? null,
|
||||
brandBgColor: s.brand_bg_color ?? null,
|
||||
brandTextColor: s.brand_text_color ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { getCurrentAdminUser } = await import("@/actions/admin-user");
|
||||
const adminUser = await getCurrentAdminUser();
|
||||
setIsAdmin(!!adminUser);
|
||||
dispatch({ type: "SET_IS_ADMIN", isAdmin: !!adminUser });
|
||||
} catch {
|
||||
// not logged in as admin
|
||||
}
|
||||
@@ -498,8 +854,8 @@ export default function TuxedoPage() {
|
||||
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
|
||||
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
|
||||
]);
|
||||
setStops(stopsData ?? []);
|
||||
setProducts(productsData ?? []);
|
||||
dispatch({ type: "SET_STOPS", stops: stopsData ?? [] });
|
||||
dispatch({ type: "SET_PRODUCTS", products: productsData ?? [] });
|
||||
}
|
||||
}
|
||||
load();
|
||||
@@ -620,42 +976,31 @@ export default function TuxedoPage() {
|
||||
return (
|
||||
<div ref={pageScopeRef} className="min-h-screen bg-stone-50">
|
||||
<BrandStylesProvider
|
||||
primaryColor={brandPrimaryColor}
|
||||
bgColor={brandBgColor}
|
||||
textColor={brandTextColor}
|
||||
primaryColor={state.brandPrimaryColor}
|
||||
bgColor={state.brandBgColor}
|
||||
textColor={state.brandTextColor}
|
||||
/>
|
||||
<StorefrontHeader
|
||||
brandName={brand?.name ?? "Tuxedo Corn"}
|
||||
brandName={state.brand?.name ?? "Tuxedo Corn"}
|
||||
brandSlug="tuxedo"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
showWholesaleLink={showWholesaleLink}
|
||||
isAdmin={isAdmin}
|
||||
logoUrl={state.logoUrl}
|
||||
logoUrlDark={state.logoUrlDark}
|
||||
showWholesaleLink={state.showWholesaleLink}
|
||||
isAdmin={state.isAdmin}
|
||||
brandAccent="green"
|
||||
/>
|
||||
|
||||
<main>
|
||||
{showWholesaleLink && wholesaleEnabled && (
|
||||
<div className="bg-stone-950 border-b border-zinc-800 py-3.5">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<Link
|
||||
href="/wholesale/login"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-emerald-700 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-600 active:bg-emerald-800 transition-all duration-200 hover:-translate-y-0.5"
|
||||
>
|
||||
Wholesale Portal →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<WholesaleBar visible={state.showWholesaleLink && state.wholesaleEnabled} />
|
||||
|
||||
<TuxedoVideoHero
|
||||
eyebrow="Olathe Sweet™ — Olathe, Colorado"
|
||||
title="Tuxedo Corn"
|
||||
description={
|
||||
heroTagline ??
|
||||
state.heroTagline ??
|
||||
"Premium Olathe Sweet™ sweet corn — hand-picked at peak freshness from our family farm in Colorado."
|
||||
}
|
||||
olatheSweetLogoUrl={olatheSweetLogoUrl}
|
||||
olatheSweetLogoUrl={state.olatheSweetLogoUrl}
|
||||
primaryButton="Find a Stop"
|
||||
secondaryButton="Our Story"
|
||||
onPrimaryClick={scrollToStops}
|
||||
@@ -664,273 +1009,28 @@ export default function TuxedoPage() {
|
||||
|
||||
<WhyTuxedoCorn />
|
||||
|
||||
<section id="stops" className="relative bg-gradient-to-b from-stone-100 to-stone-50 scroll-mt-20">
|
||||
{/* Parallax background layers */}
|
||||
<ParallaxLayer speed={0.2} className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-[40%] h-full bg-gradient-to-l from-emerald-50/30 to-transparent" />
|
||||
</ParallaxLayer>
|
||||
<StopsSection stops={state.stops} showSchedulePdf={state.showSchedulePdf} />
|
||||
|
||||
<div className="relative py-28">
|
||||
<LayoutContainer>
|
||||
<div className="mb-14 flex items-end justify-between">
|
||||
<div>
|
||||
<FadeOnScroll from="left">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Delivery Stops</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.1}>
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
|
||||
Upcoming<br className="hidden md:block" /> Stops
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.15}>
|
||||
<div className="mt-6 h-px w-12 bg-emerald-600" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.2}>
|
||||
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
|
||||
Find a nearby stop and preorder your corn. Pickup is easy and guaranteed.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
{showSchedulePdf && (
|
||||
<FadeOnScroll from="right" delay={0.3}>
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="shrink-0 hidden md:inline-flex items-center gap-2.5 rounded-2xl bg-stone-900 px-6 py-3.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-950 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Download Schedule
|
||||
</Link>
|
||||
</FadeOnScroll>
|
||||
)}
|
||||
</div>
|
||||
<FadeOnScroll from="up" delay={0.3}>
|
||||
{stops.length === 0 ? (
|
||||
<div className="rounded-3xl bg-white p-12 sm:p-16 text-center ring-1 ring-stone-200/60">
|
||||
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-stone-100">
|
||||
<svg className="h-7 w-7 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-stone-800">No stops on the calendar just yet</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-stone-500">
|
||||
The harvest is right around the corner — new pickup stops go live weekly. You can still preorder below and pick a stop once they're announced.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/tuxedo/stops"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-5 py-2.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
|
||||
>
|
||||
View All Stops
|
||||
</Link>
|
||||
{showSchedulePdf && (
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Download Schedule
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PaginatedStops stops={stops} brandSlug="tuxedo" brandAccent="green" />
|
||||
)}
|
||||
</FadeOnScroll>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
</section>
|
||||
<SectionDivider />
|
||||
|
||||
{/* Section divider */}
|
||||
<div className="relative h-24 bg-stone-50 overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="h-px w-24 bg-gradient-to-r from-transparent via-stone-300 to-transparent" />
|
||||
<svg className="h-6 w-6 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v13m0 0V8m0 13V4m0 9H4m8 0h8m-8-4h8" />
|
||||
</svg>
|
||||
<div className="h-px w-24 bg-gradient-to-r from-transparent via-stone-300 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProductsSection
|
||||
products={state.products}
|
||||
brandName={state.brand?.name ?? "Tuxedo Corn"}
|
||||
olatheSweetLogoUrlDark={state.olatheSweetLogoUrlDark}
|
||||
/>
|
||||
|
||||
<section id="products" className="relative bg-white">
|
||||
{/* Scroll-driven reveal for section header */}
|
||||
<div className="py-20 bg-gradient-to-b from-stone-50 to-white">
|
||||
<LayoutContainer>
|
||||
<ScrollReveal from="up" className="mb-14">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Farm-Direct</p>
|
||||
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
|
||||
This Week's<br className="hidden md:block" /> Harvest
|
||||
</h2>
|
||||
<div className="mt-6 h-px w-12 bg-emerald-600" />
|
||||
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
|
||||
Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
|
||||
{/* Cinematic showcase replaces static grid */}
|
||||
<CinematicShowcase
|
||||
products={products.slice(0, 3).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description ?? "",
|
||||
price: `$${p.price}`,
|
||||
type: p.type,
|
||||
imageUrl: p.image_url,
|
||||
brand_id: p.brand_id,
|
||||
brand_slug: "tuxedo",
|
||||
is_taxable: p.is_taxable,
|
||||
pickup_type: p.pickup_type,
|
||||
}))}
|
||||
brandSlug="tuxedo"
|
||||
brandName={brand?.name ?? "Tuxedo Corn"}
|
||||
/>
|
||||
|
||||
{/* Mobile-friendly fallback grid for smaller screens */}
|
||||
<div className="md:hidden py-16 bg-stone-50">
|
||||
<LayoutContainer>
|
||||
<div className="grid gap-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.slice(0, 6).map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
description={product.description}
|
||||
price={`$${product.price}`}
|
||||
type={product.type}
|
||||
imageUrl={product.image_url}
|
||||
brandSlug="tuxedo"
|
||||
brandName="Tuxedo Corn"
|
||||
brandId={product.brand_id}
|
||||
brandAccent="green"
|
||||
is_taxable={product.is_taxable}
|
||||
pickup_type={product.pickup_type}
|
||||
olatheSweetLogoUrlDark={olatheSweetLogoUrlDark}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
|
||||
{products.length > 3 && (
|
||||
<div className="mt-10 text-center pb-16">
|
||||
<p className="text-sm text-stone-400">{products.length - 3} more products available in the full catalog.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="story" className="py-32 bg-stone-950 relative overflow-hidden">
|
||||
{/* Parallax decorative elements */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<ParallaxLayer speed={0.3} className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-5" style={{
|
||||
background: "radial-gradient(circle, rgba(16,185,129,0.3) 0%, transparent 70%)",
|
||||
filter: "blur(8px)",
|
||||
}}>
|
||||
<div />
|
||||
</ParallaxLayer>
|
||||
<ParallaxLayer speed={0.5} className="absolute bottom-1/4 right-1/4 w-64 h-64 rounded-full opacity-5" style={{
|
||||
background: "radial-gradient(circle, rgba(255,215,0,0.3) 0%, transparent 70%)",
|
||||
filter: "blur(8px)",
|
||||
}}>
|
||||
<div />
|
||||
</ParallaxLayer>
|
||||
</div>
|
||||
|
||||
{/* Large background text for parallax depth */}
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden opacity-5 pointer-events-none">
|
||||
<ParallaxLayer speed={0.2}>
|
||||
<span className="text-[20vw] font-black text-white leading-none">SINCE</span>
|
||||
</ParallaxLayer>
|
||||
</div>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="text-center max-w-3xl mx-auto relative z-10">
|
||||
<FadeOnScroll from="up" className="mb-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-400/60 mb-4">Our Story</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.1}>
|
||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-white leading-[1.05] mb-8">
|
||||
Three Generations of<br className="hidden md:block" /> Sweet Corn Excellence
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.2}>
|
||||
<div className="mx-auto mt-8 mb-10 h-px w-16 bg-gradient-to-r from-emerald-600 to-amber-500" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.3}>
|
||||
<p className="text-stone-400 leading-relaxed text-lg md:text-xl max-w-2xl mx-auto">
|
||||
Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn — developed for Colorado's high-altitude mountain climate and grown by the same family for over 40 years.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
{/* Stats with counter animation */}
|
||||
<FadeOnScroll from="up" delay={0.4}>
|
||||
<div className="flex items-center justify-center gap-12 md:gap-16 mt-16 flex-wrap">
|
||||
{[
|
||||
{ stat: "40", suffix: "+", label: "Years Growing" },
|
||||
{ stat: "3", suffix: "", label: "Generations" },
|
||||
{ stat: "100", suffix: "%", label: "Hand-Picked" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div className="text-4xl md:text-5xl lg:text-6xl font-black text-white" style={{ textShadow: "0 4px 30px rgba(16,185,129,0.3)" }}>
|
||||
<span className="counter-animate" data-target={parseInt(item.stat, 10)}>0</span>
|
||||
<span style={{ color: "#10b981" }}>{item.suffix}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] uppercase tracking-[0.2em] text-stone-500">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.5}>
|
||||
<div className="mt-16">
|
||||
<Link
|
||||
href="/tuxedo/about"
|
||||
className="group inline-flex items-center gap-3 rounded-full px-10 py-4 text-sm font-bold transition-all duration-300"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
|
||||
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-3px)";
|
||||
e.currentTarget.style.boxShadow = "0 12px 40px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow = "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)";
|
||||
}}
|
||||
>
|
||||
<span>Read Our Story</span>
|
||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
<StorySection />
|
||||
</main>
|
||||
|
||||
<StorefrontFooter
|
||||
brandName={brand?.name ?? "Tuxedo Corn"}
|
||||
brandName={state.brand?.name ?? "Tuxedo Corn"}
|
||||
brandSlug="tuxedo"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
customFooterText={customFooterText}
|
||||
contactEmail={contactEmail}
|
||||
contactPhone={contactPhone}
|
||||
isAdmin={isAdmin}
|
||||
logoUrl={state.logoUrl}
|
||||
logoUrlDark={state.logoUrlDark}
|
||||
customFooterText={state.customFooterText}
|
||||
contactEmail={state.contactEmail}
|
||||
contactPhone={state.contactPhone}
|
||||
isAdmin={state.isAdmin}
|
||||
brandAccent="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { useReducer, useEffect, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
type WholesaleOrder,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
markWholesaleOrderFulfilled,
|
||||
} from "@/actions/wholesale";
|
||||
import { wholesaleEmployeeLogoutAction } from "@/actions/wholesale-auth";
|
||||
import { usePrevious } from "@/lib/use-prev";
|
||||
import { openHtmlInPopup } from "@/lib/safe-window";
|
||||
import DepositModal from "@/components/wholesale/DepositModal";
|
||||
import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal";
|
||||
@@ -30,6 +29,86 @@ const STATUS_BADGE_MAP: Record<string, string> = {
|
||||
cancelled: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
type Msg = { kind: "success" | "error"; text: string };
|
||||
|
||||
type State = {
|
||||
orders: WholesaleOrder[];
|
||||
activeTab: Queue;
|
||||
showViewOrder: WholesaleOrder | null;
|
||||
showDepForm: string | null;
|
||||
fulfilling: string | null;
|
||||
openActions: string | null;
|
||||
msg: Msg | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_ORDERS"; orders: WholesaleOrder[] }
|
||||
| { type: "SET_ACTIVE_TAB"; tab: Queue }
|
||||
| { type: "SET_SHOW_VIEW_ORDER"; order: WholesaleOrder | null }
|
||||
| { type: "SET_SHOW_DEP_FORM"; id: string | null }
|
||||
| { type: "SET_FULFILLING"; id: string | null }
|
||||
| { type: "TOGGLE_ACTIONS"; id: string }
|
||||
| { type: "CLOSE_ACTIONS" }
|
||||
| { type: "SET_MSG"; msg: Msg }
|
||||
| { type: "CLEAR_MSG" };
|
||||
|
||||
const initialState = (initialOrders: WholesaleOrder[]): State => ({
|
||||
orders: initialOrders,
|
||||
activeTab: "today",
|
||||
showViewOrder: null,
|
||||
showDepForm: null,
|
||||
fulfilling: null,
|
||||
openActions: null,
|
||||
msg: null,
|
||||
});
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_ORDERS":
|
||||
return { ...state, orders: action.orders };
|
||||
case "SET_ACTIVE_TAB":
|
||||
return { ...state, activeTab: action.tab };
|
||||
case "SET_SHOW_VIEW_ORDER":
|
||||
return { ...state, showViewOrder: action.order };
|
||||
case "SET_SHOW_DEP_FORM":
|
||||
return { ...state, showDepForm: action.id };
|
||||
case "SET_FULFILLING":
|
||||
return { ...state, fulfilling: action.id };
|
||||
case "TOGGLE_ACTIONS":
|
||||
return {
|
||||
...state,
|
||||
openActions: state.openActions === action.id ? null : action.id,
|
||||
};
|
||||
case "CLOSE_ACTIONS":
|
||||
return { ...state, openActions: null };
|
||||
case "SET_MSG":
|
||||
return { ...state, msg: action.msg };
|
||||
case "CLEAR_MSG":
|
||||
return { ...state, msg: null };
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_STYLES: Record<Queue, { activeBorder: string; activeText: string; countBg: string; countText: string }> = {
|
||||
past_due: {
|
||||
activeBorder: "border-red-500",
|
||||
activeText: "text-red-700",
|
||||
countBg: "bg-red-100",
|
||||
countText: "text-red-700",
|
||||
},
|
||||
today: {
|
||||
activeBorder: "border-yellow-500",
|
||||
activeText: "text-yellow-700",
|
||||
countBg: "bg-yellow-100",
|
||||
countText: "text-yellow-700",
|
||||
},
|
||||
upcoming: {
|
||||
activeBorder: "border-blue-500",
|
||||
activeText: "text-blue-700",
|
||||
countBg: "bg-blue-100",
|
||||
countText: "text-blue-700",
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE_MAP[status] ?? "bg-slate-100 text-slate-600"}`}>
|
||||
@@ -40,27 +119,14 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
export default function EmployeePortalClient({ brandId, brandName, employeeName, initialOrders }: Props) {
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<WholesaleOrder[]>(initialOrders);
|
||||
const [activeTab, setActiveTab] = useState<Queue>("today");
|
||||
const [showViewOrder, setShowViewOrder] = useState<WholesaleOrder | null>(null);
|
||||
const [showDepForm, setShowDepForm] = useState<string | null>(null);
|
||||
const [fulfilling, setFulfilling] = useState<string | null>(null);
|
||||
const [openActions, setOpenActions] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState(initialOrders));
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
async function handleSignOut() {
|
||||
startTransition(async () => {
|
||||
await wholesaleEmployeeLogoutAction();
|
||||
router.push("/wholesale/login");
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!(e.target as Element).closest(".actions-cell")) {
|
||||
setOpenActions(null);
|
||||
dispatch({ type: "CLOSE_ACTIONS" });
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
@@ -69,172 +135,299 @@ export default function EmployeePortalClient({ brandId, brandName, employeeName,
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const pastDue = orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date < today);
|
||||
const todayOrders = orders.filter((o) => o.anticipated_pickup_date === today);
|
||||
const upcoming = orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date > today);
|
||||
const pastDue = state.orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date < today);
|
||||
const todayOrders = state.orders.filter((o) => o.anticipated_pickup_date === today);
|
||||
const upcoming = state.orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date > today);
|
||||
|
||||
const tabData: Record<Queue, { label: string; orders: WholesaleOrder[]; color: string; bg: string }> = {
|
||||
past_due: { label: "Past Due", orders: pastDue, color: "text-red-700", bg: "bg-red-50 border-red-200" },
|
||||
today: { label: "Today", orders: todayOrders, color: "text-yellow-700", bg: "bg-yellow-50 border-yellow-200" },
|
||||
upcoming: { label: "Upcoming", orders: upcoming, color: "text-blue-700", bg: "bg-blue-50 border-blue-200" },
|
||||
const tabData: Record<Queue, { label: string; orders: WholesaleOrder[] }> = {
|
||||
past_due: { label: "Past Due", orders: pastDue },
|
||||
today: { label: "Today", orders: todayOrders },
|
||||
upcoming: { label: "Upcoming", orders: upcoming },
|
||||
};
|
||||
|
||||
const currentTab = tabData[activeTab];
|
||||
const currentTab = tabData[state.activeTab];
|
||||
|
||||
async function handleSignOut() {
|
||||
startTransition(async () => {
|
||||
await wholesaleEmployeeLogoutAction();
|
||||
router.push("/wholesale/login");
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFulfill(orderId: string) {
|
||||
setFulfilling(orderId);
|
||||
dispatch({ type: "SET_FULFILLING", id: orderId });
|
||||
const result = await markWholesaleOrderFulfilled(orderId);
|
||||
setFulfilling(null);
|
||||
dispatch({ type: "SET_FULFILLING", id: null });
|
||||
if (result.success) {
|
||||
setMsg({ kind: "success", text: "Order marked as fulfilled." });
|
||||
dispatch({ type: "SET_MSG", msg: { kind: "success", text: "Order marked as fulfilled." } });
|
||||
const data = await getWholesalePickupOrders(brandId);
|
||||
setOrders(data);
|
||||
dispatch({ type: "SET_ORDERS", orders: data });
|
||||
} else {
|
||||
setMsg({ kind: "error", text: result.error ?? "Failed to fulfill order." });
|
||||
dispatch({ type: "SET_MSG", msg: { kind: "error", text: result.error ?? "Failed to fulfill order." } });
|
||||
}
|
||||
setTimeout(() => setMsg(null), 4000);
|
||||
setTimeout(() => dispatch({ type: "CLEAR_MSG" }), 4000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="mx-auto max-w-5xl flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 truncate">Pickup Portal</h1>
|
||||
<p className="mt-0.5 text-xs sm:text-sm text-slate-500 truncate">{brandName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs sm:text-sm text-slate-500 hidden sm:inline">{employeeName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="rounded-xl border border-slate-300 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors min-h-[40px]"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortalHeader
|
||||
brandName={brandName}
|
||||
employeeName={employeeName}
|
||||
onSignOut={handleSignOut}
|
||||
/>
|
||||
|
||||
{/* Queue tabs */}
|
||||
<div className="bg-white border-b border-slate-200 px-4 sm:px-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<nav className="flex gap-1 -mb-px overflow-x-auto">
|
||||
{(["past_due", "today", "upcoming"] as Queue[]).map((tab) => {
|
||||
const data = tabData[tab];
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
|
||||
isActive
|
||||
? tab === "past_due" ? "border-red-500 text-red-700" :
|
||||
tab === "today" ? "border-yellow-500 text-yellow-700" :
|
||||
"border-blue-500 text-blue-700"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{data.label}
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-bold ${
|
||||
tab === "past_due" ? "bg-red-100 text-red-700" :
|
||||
tab === "today" ? "bg-yellow-100 text-yellow-700" :
|
||||
"bg-blue-100 text-blue-700"
|
||||
}`}>
|
||||
{data.orders.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<QueueTabs
|
||||
activeTab={state.activeTab}
|
||||
tabData={tabData}
|
||||
onChange={(tab) => dispatch({ type: "SET_ACTIVE_TAB", tab })}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 py-6">
|
||||
{msg && (
|
||||
<div className={`mb-4 rounded-xl border px-4 py-3 text-sm ${
|
||||
msg.kind === "success" ? "border-green-200 bg-green-50 text-green-700" : "border-red-200 bg-red-50 text-red-700"
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
<MessageBanner msg={state.msg} />
|
||||
|
||||
{currentTab.orders.length === 0 ? (
|
||||
<EmptyQueueState
|
||||
label={currentTab.label === "Past Due" ? "No past due orders" :
|
||||
currentTab.label === "Today" ? "No pickups scheduled today" :
|
||||
"No upcoming pickups"}
|
||||
description={currentTab.label === "Past Due" ?
|
||||
"All orders are on schedule. Great work!" :
|
||||
currentTab.label === "Today" ?
|
||||
"There are no wholesale orders scheduled for pickup today." :
|
||||
"You have no upcoming pickups scheduled."}
|
||||
label={
|
||||
currentTab.label === "Past Due"
|
||||
? "No past due orders"
|
||||
: currentTab.label === "Today"
|
||||
? "No pickups scheduled today"
|
||||
: "No upcoming pickups"
|
||||
}
|
||||
description={
|
||||
currentTab.label === "Past Due"
|
||||
? "All orders are on schedule. Great work!"
|
||||
: currentTab.label === "Today"
|
||||
? "There are no wholesale orders scheduled for pickup today."
|
||||
: "You have no upcoming pickups scheduled."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{currentTab.orders.map((order) => (
|
||||
<OrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
fulfilling={fulfilling}
|
||||
openActions={openActions}
|
||||
onToggleActions={() => {
|
||||
setOpenActions(openActions === order.id ? null : order.id);
|
||||
}}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={() => setShowDepForm(order.id)}
|
||||
onViewDetails={() => setShowViewOrder(order)}
|
||||
onGenerateManifest={() => {
|
||||
fetch("/api/wholesale/manifest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, orders: [order] }),
|
||||
}).then(r => r.text()).then(html => openHtmlInPopup(html));
|
||||
}}
|
||||
onSendPriceSheet={() => {
|
||||
fetch("/api/wholesale/price-sheet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customerIds: [order.customer_id], brandId: "" }),
|
||||
}).then(r => r.json()).then(d => {
|
||||
setMsg({ kind: "success", text: `Price sheet sent to ${order.company_name}.` });
|
||||
}).catch(() => setMsg({ kind: "error", text: "Failed to send price sheet." }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<OrdersList
|
||||
orders={currentTab.orders}
|
||||
fulfilling={state.fulfilling}
|
||||
openActions={state.openActions}
|
||||
onToggleActions={(id) => dispatch({ type: "TOGGLE_ACTIONS", id })}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={(id) => dispatch({ type: "SET_SHOW_DEP_FORM", id })}
|
||||
onViewDetails={(order) => dispatch({ type: "SET_SHOW_VIEW_ORDER", order })}
|
||||
onSendPriceSheet={(order) => {
|
||||
fetch("/api/wholesale/price-sheet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customerIds: [order.customer_id], brandId: "" }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(() => {
|
||||
dispatch({
|
||||
type: "SET_MSG",
|
||||
msg: { kind: "success", text: `Price sheet sent to ${order.company_name}.` },
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch({ type: "SET_MSG", msg: { kind: "error", text: "Failed to send price sheet." } });
|
||||
});
|
||||
}}
|
||||
onGenerateManifest={(order) => {
|
||||
fetch("/api/wholesale/manifest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, orders: [order] }),
|
||||
})
|
||||
.then((r) => r.text())
|
||||
.then((html) => openHtmlInPopup(html));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showViewOrder && (
|
||||
<OrderDetailsModal
|
||||
order={showViewOrder}
|
||||
onClose={() => setShowViewOrder(null)}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={(id) => { setShowViewOrder(null); setShowDepForm(id); }}
|
||||
<PortalModals
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
brandId={brandId}
|
||||
handleFulfill={handleFulfill}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Portal Header ─────────────────────────────────────────────────────────────
|
||||
function PortalHeader({
|
||||
brandName,
|
||||
employeeName,
|
||||
onSignOut,
|
||||
}: {
|
||||
brandName: string;
|
||||
employeeName: string;
|
||||
onSignOut: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border-b border-slate-200 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="mx-auto max-w-5xl flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 truncate">Pickup Portal</h1>
|
||||
<p className="mt-0.5 text-xs sm:text-sm text-slate-500 truncate">{brandName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs sm:text-sm text-slate-500 hidden sm:inline">{employeeName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSignOut}
|
||||
className="rounded-xl border border-slate-300 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors min-h-[40px]"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Queue Tabs ────────────────────────────────────────────────────────────────
|
||||
function QueueTabs({
|
||||
activeTab,
|
||||
tabData,
|
||||
onChange,
|
||||
}: {
|
||||
activeTab: Queue;
|
||||
tabData: Record<Queue, { label: string; orders: WholesaleOrder[] }>;
|
||||
onChange: (tab: Queue) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border-b border-slate-200 px-4 sm:px-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<nav className="flex gap-1 -mb-px overflow-x-auto">
|
||||
{(["past_due", "today", "upcoming"] as Queue[]).map((tab) => {
|
||||
const data = tabData[tab];
|
||||
const isActive = activeTab === tab;
|
||||
const styles = TAB_STYLES[tab];
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => onChange(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
|
||||
isActive
|
||||
? `${styles.activeBorder} ${styles.activeText}`
|
||||
: "border-transparent text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{data.label}
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-bold ${styles.countBg} ${styles.countText}`}>
|
||||
{data.orders.length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Message Banner ────────────────────────────────────────────────────────────
|
||||
function MessageBanner({ msg }: { msg: Msg | null }) {
|
||||
if (!msg) return null;
|
||||
return (
|
||||
<div
|
||||
className={`mb-4 rounded-xl border px-4 py-3 text-sm ${
|
||||
msg.kind === "success"
|
||||
? "border-green-200 bg-green-50 text-green-700"
|
||||
: "border-red-200 bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Orders List ───────────────────────────────────────────────────────────────
|
||||
type OrdersListProps = {
|
||||
orders: WholesaleOrder[];
|
||||
fulfilling: string | null;
|
||||
openActions: string | null;
|
||||
onToggleActions: (id: string) => void;
|
||||
onFulfill: (id: string) => void;
|
||||
onRecordDeposit: (id: string) => void;
|
||||
onViewDetails: (order: WholesaleOrder) => void;
|
||||
onGenerateManifest: (order: WholesaleOrder) => void;
|
||||
onSendPriceSheet: (order: WholesaleOrder) => void;
|
||||
};
|
||||
|
||||
function OrdersList({
|
||||
orders,
|
||||
fulfilling,
|
||||
openActions,
|
||||
onToggleActions,
|
||||
onFulfill,
|
||||
onRecordDeposit,
|
||||
onViewDetails,
|
||||
onGenerateManifest,
|
||||
onSendPriceSheet,
|
||||
}: OrdersListProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{orders.map((order) => (
|
||||
<OrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
fulfilling={fulfilling}
|
||||
openActions={openActions}
|
||||
onToggleActions={() => onToggleActions(order.id)}
|
||||
onFulfill={() => onFulfill(order.id)}
|
||||
onRecordDeposit={() => onRecordDeposit(order.id)}
|
||||
onViewDetails={() => onViewDetails(order)}
|
||||
onGenerateManifest={() => onGenerateManifest(order)}
|
||||
onSendPriceSheet={() => onSendPriceSheet(order)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Portal Modals ─────────────────────────────────────────────────────────────
|
||||
function PortalModals({
|
||||
state,
|
||||
dispatch,
|
||||
brandId,
|
||||
handleFulfill,
|
||||
}: {
|
||||
state: State;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
brandId: string;
|
||||
handleFulfill: (orderId: string) => void;
|
||||
}) {
|
||||
const depositOrder = state.showDepForm
|
||||
? state.orders.find((o) => o.id === state.showDepForm) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.showViewOrder && (
|
||||
<OrderDetailsModal
|
||||
order={state.showViewOrder}
|
||||
onClose={() => dispatch({ type: "SET_SHOW_VIEW_ORDER", order: null })}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={(id) => {
|
||||
dispatch({ type: "SET_SHOW_VIEW_ORDER", order: null });
|
||||
dispatch({ type: "SET_SHOW_DEP_FORM", id });
|
||||
}}
|
||||
fulfilling={state.fulfilling}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDepForm && (() => {
|
||||
const order = orders.find(o => o.id === showDepForm);
|
||||
if (!order) return null;
|
||||
return (
|
||||
<DepositModal
|
||||
order={order}
|
||||
onClose={() => setShowDepForm(null)}
|
||||
onFulfilled={async () => {
|
||||
setMsg({ kind: "success", text: "Deposit recorded." });
|
||||
const data = await getWholesalePickupOrders(brandId);
|
||||
setOrders(data);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{depositOrder && (
|
||||
<DepositModal
|
||||
order={depositOrder}
|
||||
onClose={() => dispatch({ type: "SET_SHOW_DEP_FORM", id: null })}
|
||||
onFulfilled={async () => {
|
||||
dispatch({ type: "SET_MSG", msg: { kind: "success", text: "Deposit recorded." } });
|
||||
const data = await getWholesalePickupOrders(brandId);
|
||||
dispatch({ type: "SET_ORDERS", orders: data });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,14 +447,14 @@ function EmployeePortalSkeleton() {
|
||||
<div className="bg-white border-b border-slate-200 px-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex gap-1 -mb-px py-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-9 w-20 bg-slate-200 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl px-6 py-6 space-y-3">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 animate-pulse">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2 flex-1">
|
||||
@@ -361,7 +554,7 @@ function OrderCard({
|
||||
</button>
|
||||
)}
|
||||
{Number(order.balance_due) > 0 && order.fulfillment_status !== "fulfilled" && (
|
||||
<button aria-label="Record deposit"
|
||||
<button aria-label="Record deposit"
|
||||
type="button"
|
||||
onClick={onRecordDeposit} title="Record deposit"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-purple-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-purple-700 transition-colors">
|
||||
@@ -369,7 +562,7 @@ function OrderCard({
|
||||
Deposit
|
||||
</button>
|
||||
)}
|
||||
<a aria-label="Download Invoice (PDF)"
|
||||
<a aria-label="Download Invoice (PDF)"
|
||||
href={`/api/wholesale/invoice/${order.id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer" title="Download Invoice (PDF)"
|
||||
@@ -377,7 +570,7 @@ function OrderCard({
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 110 12H15M6 2h9l4 4v14a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/></svg>
|
||||
</a>
|
||||
<div className="relative">
|
||||
<button aria-label="More actions"
|
||||
<button aria-label="More actions"
|
||||
type="button"
|
||||
onClick={onToggleActions} title="More actions"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
|
||||
@@ -386,7 +579,7 @@ function OrderCard({
|
||||
{openActions === order.id && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-1 z-30 w-56 rounded-xl bg-white shadow-xl ring-1 ring-slate-200 py-1 text-sm"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={() => { onToggleActions(); onViewDetails(); }} className="w-full text-left px-4 py-3 text-slate-700 hover:bg-slate-50 flex items-center gap-3">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
@@ -438,4 +631,4 @@ function OrderCard({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+277
-167
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { registerWholesaleCustomer } from "@/actions/wholesale-register";
|
||||
import { useRouter } from "next/navigation";
|
||||
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
|
||||
@@ -31,20 +31,242 @@ function FormField({ label, id, error, hint, children }: {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Reducer state -------------------------------------------------------
|
||||
|
||||
type Form = {
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
checkingEnabled: boolean;
|
||||
portalDisabled: boolean;
|
||||
form: Form;
|
||||
fieldErrors: Record<string, string>;
|
||||
submitting: boolean;
|
||||
result: { success: boolean; message: string } | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_CHECKING_ENABLED"; value: boolean }
|
||||
| { type: "SET_PORTAL_DISABLED"; value: boolean }
|
||||
| { type: "SET_FORM_FIELD"; field: keyof Form; value: string }
|
||||
| { type: "SET_FIELD_ERRORS"; errors: Record<string, string> }
|
||||
| { type: "CLEAR_FIELD_ERROR"; field: string }
|
||||
| { type: "SET_SUBMITTING"; value: boolean }
|
||||
| { type: "SET_RESULT"; result: { success: boolean; message: string } | null };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_CHECKING_ENABLED":
|
||||
return { ...state, checkingEnabled: action.value };
|
||||
case "SET_PORTAL_DISABLED":
|
||||
return { ...state, portalDisabled: action.value };
|
||||
case "SET_FORM_FIELD":
|
||||
return { ...state, form: { ...state.form, [action.field]: action.value } };
|
||||
case "SET_FIELD_ERRORS":
|
||||
return { ...state, fieldErrors: action.errors };
|
||||
case "CLEAR_FIELD_ERROR":
|
||||
return { ...state, fieldErrors: { ...state.fieldErrors, [action.field]: "" } };
|
||||
case "SET_SUBMITTING":
|
||||
return { ...state, submitting: action.value };
|
||||
case "SET_RESULT":
|
||||
return { ...state, result: action.result };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function createInitialState(): State {
|
||||
return {
|
||||
checkingEnabled: true,
|
||||
portalDisabled: false,
|
||||
form: {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
brandId: TUXEDO_BRAND_ID,
|
||||
},
|
||||
fieldErrors: {},
|
||||
submitting: false,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Subcomponents -------------------------------------------------------
|
||||
|
||||
type RegisterFormProps = {
|
||||
form: Form;
|
||||
fieldErrors: Record<string, string>;
|
||||
submitting: boolean;
|
||||
onChangeField: (field: keyof Form, value: string) => void;
|
||||
onClearFieldError: (field: string) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
};
|
||||
|
||||
function RegisterForm({
|
||||
form,
|
||||
fieldErrors,
|
||||
submitting,
|
||||
onChangeField,
|
||||
onClearFieldError,
|
||||
onSubmit,
|
||||
}: RegisterFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<FormField label="Company Name" id="companyName" error={fieldErrors.companyName} hint={null}>
|
||||
<input aria-label="Farm Or Business Name"
|
||||
id="companyName"
|
||||
value={form.companyName}
|
||||
onChange={e => { onChangeField("companyName", e.target.value); onClearFieldError("companyName"); }}
|
||||
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.companyName ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
|
||||
placeholder="Farm or business name"
|
||||
autoComplete="organization"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Contact Name" id="contactName" error={null} hint="Optional — your name">
|
||||
<input aria-label="Your Name"
|
||||
id="contactName"
|
||||
value={form.contactName}
|
||||
onChange={e => onChangeField("contactName", e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Email" id="email" error={fieldErrors.email} hint={null}>
|
||||
<input aria-label="Order@company.com"
|
||||
type="email"
|
||||
id="email"
|
||||
value={form.email}
|
||||
onChange={e => { onChangeField("email", e.target.value); onClearFieldError("email"); }}
|
||||
required
|
||||
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.email ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
|
||||
placeholder="order@company.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Phone" id="phone" error={fieldErrors.phone} hint="Optional — for order coordination">
|
||||
<input aria-label="(555) 555 5555"
|
||||
type="tel"
|
||||
id="phone"
|
||||
value={form.phone}
|
||||
onChange={e => { onChangeField("phone", e.target.value); onClearFieldError("phone"); }}
|
||||
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.phone ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
|
||||
placeholder="(555) 555-5555"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Brand" id="brandId" error={null} hint={null}>
|
||||
<select aria-label="BrandId"
|
||||
id="brandId"
|
||||
value={form.brandId}
|
||||
onChange={e => onChangeField("brandId", e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors appearance-none cursor-pointer"
|
||||
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center", backgroundSize: "16px" }}
|
||||
>
|
||||
<option value={TUXEDO_BRAND_ID}>Tuxedo Corn — Colorado Sweet Corn</option>
|
||||
<option value={IRD_BRAND_ID}>Indian River Direct — Florida Citrus</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-xl bg-emerald-600 py-3.5 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
Submit Application
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type ResultBannerProps = {
|
||||
result: { success: boolean; message: string } | null;
|
||||
};
|
||||
|
||||
function ResultBanner({ result }: ResultBannerProps) {
|
||||
if (!result) return null;
|
||||
return (
|
||||
<div className={`mb-5 rounded-xl border px-4 py-4 text-sm flex items-start gap-2 ${
|
||||
result.success
|
||||
? "border-emerald-900/50 bg-emerald-950/50 text-emerald-400"
|
||||
: "border-red-900/50 bg-red-950/50 text-red-400"
|
||||
}`}>
|
||||
{result.success ? (
|
||||
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
)}
|
||||
{result.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessPanel() {
|
||||
return (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-16 h-16 bg-emerald-900/40 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<Link
|
||||
href="/wholesale/login"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-bold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Go to Sign In
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 rounded-full border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main component ------------------------------------------------------
|
||||
|
||||
export default function WholesaleRegisterPage() {
|
||||
const router = useRouter();
|
||||
const [checkingEnabled, setCheckingEnabled] = useState(true);
|
||||
const [portalDisabled, setPortalDisabled] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
brandId: TUXEDO_BRAND_ID,
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, createInitialState());
|
||||
|
||||
useEffect(() => {
|
||||
async function checkEnabled(brandId: string) {
|
||||
@@ -55,24 +277,24 @@ export default function WholesaleRegisterPage() {
|
||||
.eq("brand_id", brandId)
|
||||
.single();
|
||||
if (ws && ws.wholesale_enabled === false) {
|
||||
setPortalDisabled(true);
|
||||
dispatch({ type: "SET_PORTAL_DISABLED", value: true });
|
||||
}
|
||||
setCheckingEnabled(false);
|
||||
dispatch({ type: "SET_CHECKING_ENABLED", value: false });
|
||||
}
|
||||
checkEnabled(form.brandId);
|
||||
}, [form.brandId]);
|
||||
checkEnabled(state.form.brandId);
|
||||
}, [state.form.brandId]);
|
||||
|
||||
function validateForm() {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!form.companyName.trim()) {
|
||||
if (!state.form.companyName.trim()) {
|
||||
errors.companyName = "Company name is required";
|
||||
}
|
||||
if (!form.email) {
|
||||
if (!state.form.email) {
|
||||
errors.email = "Email is required";
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.form.email)) {
|
||||
errors.email = "Enter a valid email address";
|
||||
}
|
||||
if (form.phone && !/^[\d\s\-\+\(\)]+$/.test(form.phone)) {
|
||||
if (state.form.phone && !/^[\d\s\-\+\(\)]+$/.test(state.form.phone)) {
|
||||
errors.phone = "Enter a valid phone number";
|
||||
}
|
||||
return errors;
|
||||
@@ -82,43 +304,39 @@ export default function WholesaleRegisterPage() {
|
||||
e.preventDefault();
|
||||
const errors = validateForm();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
dispatch({ type: "SET_FIELD_ERRORS", errors });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
setFieldErrors({});
|
||||
dispatch({ type: "SET_SUBMITTING", value: true });
|
||||
dispatch({ type: "SET_RESULT", result: null });
|
||||
dispatch({ type: "SET_FIELD_ERRORS", errors: {} });
|
||||
|
||||
const res = await registerWholesaleCustomer({
|
||||
brandId: form.brandId,
|
||||
companyName: form.companyName,
|
||||
contactName: form.contactName || undefined,
|
||||
email: form.email,
|
||||
phone: form.phone || undefined,
|
||||
brandId: state.form.brandId,
|
||||
companyName: state.form.companyName,
|
||||
contactName: state.form.contactName || undefined,
|
||||
email: state.form.email,
|
||||
phone: state.form.phone || undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
dispatch({ type: "SET_SUBMITTING", value: false });
|
||||
if (res.success) {
|
||||
setResult({
|
||||
success: true,
|
||||
message: res.requiresApproval
|
||||
? "Application submitted — we'll review your account and notify you by email within 1–2 business days."
|
||||
: "Account created — you can now log in.",
|
||||
dispatch({
|
||||
type: "SET_RESULT",
|
||||
result: {
|
||||
success: true,
|
||||
message: res.requiresApproval
|
||||
? "Application submitted — we'll review your account and notify you by email within 1–2 business days."
|
||||
: "Account created — you can now log in.",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setResult({ success: false, message: res.error ?? "Registration failed. Please try again." });
|
||||
dispatch({ type: "SET_RESULT", result: { success: false, message: res.error ?? "Registration failed. Please try again." } });
|
||||
}
|
||||
}
|
||||
|
||||
if (checkingEnabled) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 rounded-full border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (state.checkingEnabled) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -162,7 +380,7 @@ export default function WholesaleRegisterPage() {
|
||||
|
||||
{/* Form column */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 shadow-xl shadow-black/20">
|
||||
{portalDisabled && (
|
||||
{state.portalDisabled && (
|
||||
<div className="mb-5 rounded-xl border border-amber-900/50 bg-amber-950/50 px-4 py-3 text-sm text-amber-400 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
@@ -176,130 +394,22 @@ export default function WholesaleRegisterPage() {
|
||||
<p className="mt-1 text-sm text-zinc-500">We'll review and respond within 1–2 business days.</p>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className={`mb-5 rounded-xl border px-4 py-4 text-sm flex items-start gap-2 ${
|
||||
result.success
|
||||
? "border-emerald-900/50 bg-emerald-950/50 text-emerald-400"
|
||||
: "border-red-900/50 bg-red-950/50 text-red-400"
|
||||
}`}>
|
||||
{result.success ? (
|
||||
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
)}
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
<ResultBanner result={state.result} />
|
||||
|
||||
{result?.success ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-16 h-16 bg-emerald-900/40 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<Link
|
||||
href="/wholesale/login"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-bold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Go to Sign In
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
{state.result?.success ? (
|
||||
<SuccessPanel />
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<FormField label="Company Name" id="companyName" error={fieldErrors.companyName} hint={null}>
|
||||
<input aria-label="Farm Or Business Name"
|
||||
id="companyName"
|
||||
value={form.companyName}
|
||||
onChange={e => { setForm(f => ({ ...f, companyName: e.target.value })); setFieldErrors(f => ({ ...f, companyName: "" })); }}
|
||||
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.companyName ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
|
||||
placeholder="Farm or business name"
|
||||
autoComplete="organization"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Contact Name" id="contactName" error={null} hint="Optional — your name">
|
||||
<input aria-label="Your Name"
|
||||
id="contactName"
|
||||
value={form.contactName}
|
||||
onChange={e => setForm(f => ({ ...f, contactName: e.target.value }))}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
|
||||
placeholder="Your name"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Email" id="email" error={fieldErrors.email} hint={null}>
|
||||
<input aria-label="Order@company.com"
|
||||
type="email"
|
||||
id="email"
|
||||
value={form.email}
|
||||
onChange={e => { setForm(f => ({ ...f, email: e.target.value })); setFieldErrors(f => ({ ...f, email: "" })); }}
|
||||
required
|
||||
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.email ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
|
||||
placeholder="order@company.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Phone" id="phone" error={fieldErrors.phone} hint="Optional — for order coordination">
|
||||
<input aria-label="(555) 555 5555"
|
||||
type="tel"
|
||||
id="phone"
|
||||
value={form.phone}
|
||||
onChange={e => { setForm(f => ({ ...f, phone: e.target.value })); setFieldErrors(f => ({ ...f, phone: "" })); }}
|
||||
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.phone ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
|
||||
placeholder="(555) 555-5555"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Brand" id="brandId" error={null} hint={null}>
|
||||
<select aria-label="BrandId"
|
||||
id="brandId"
|
||||
value={form.brandId}
|
||||
onChange={e => setForm(f => ({ ...f, brandId: e.target.value }))}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors appearance-none cursor-pointer"
|
||||
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center", backgroundSize: "16px" }}
|
||||
>
|
||||
<option value={TUXEDO_BRAND_ID}>Tuxedo Corn — Colorado Sweet Corn</option>
|
||||
<option value={IRD_BRAND_ID}>Indian River Direct — Florida Citrus</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-xl bg-emerald-600 py-3.5 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
Submit Application
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<RegisterForm
|
||||
form={state.form}
|
||||
fieldErrors={state.fieldErrors}
|
||||
submitting={state.submitting}
|
||||
onChangeField={(field, value) => dispatch({ type: "SET_FORM_FIELD", field, value })}
|
||||
onClearFieldError={(field) => dispatch({ type: "CLEAR_FIELD_ERROR", field })}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!result?.success && (
|
||||
{!state.result?.success && (
|
||||
<div className="mt-5 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Already have an account?{" "}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useReducer, useEffect } from "react";
|
||||
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
|
||||
|
||||
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "minimax" | "custom";
|
||||
@@ -97,16 +97,94 @@ type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
type TestResult = { ok: boolean; message: string } | null;
|
||||
|
||||
type State = {
|
||||
provider: AIProvider;
|
||||
apiKey: string;
|
||||
orgId: string;
|
||||
model: string;
|
||||
customEndpoint: string;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
testResult: TestResult;
|
||||
showKey: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "HYDRATE"; provider: AIProvider; apiKey: string; orgId: string; model: string; customEndpoint: string }
|
||||
| { type: "HYDRATE_DONE" }
|
||||
| { type: "SET_PROVIDER"; provider: AIProvider; model: string }
|
||||
| { type: "SET_API_KEY"; value: string }
|
||||
| { type: "SET_ORG_ID"; value: string }
|
||||
| { type: "SET_MODEL"; value: string }
|
||||
| { type: "SET_CUSTOM_ENDPOINT"; value: string }
|
||||
| { type: "SET_SHOW_KEY"; value: boolean }
|
||||
| { type: "OP_START" }
|
||||
| { type: "OP_RESULT"; result: TestResult }
|
||||
| { type: "OP_DONE" };
|
||||
|
||||
const initialState: State = {
|
||||
provider: "openai",
|
||||
apiKey: "",
|
||||
orgId: "",
|
||||
model: "gpt-4o-mini",
|
||||
customEndpoint: "",
|
||||
loading: true,
|
||||
saving: false,
|
||||
testResult: null,
|
||||
showKey: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "HYDRATE":
|
||||
return {
|
||||
...state,
|
||||
provider: action.provider,
|
||||
apiKey: action.apiKey,
|
||||
orgId: action.orgId,
|
||||
model: action.model,
|
||||
customEndpoint: action.customEndpoint,
|
||||
};
|
||||
case "HYDRATE_DONE":
|
||||
return { ...state, loading: false };
|
||||
case "SET_PROVIDER":
|
||||
return { ...state, provider: action.provider, model: action.model };
|
||||
case "SET_API_KEY":
|
||||
return { ...state, apiKey: action.value };
|
||||
case "SET_ORG_ID":
|
||||
return { ...state, orgId: action.value };
|
||||
case "SET_MODEL":
|
||||
return { ...state, model: action.value };
|
||||
case "SET_CUSTOM_ENDPOINT":
|
||||
return { ...state, customEndpoint: action.value };
|
||||
case "SET_SHOW_KEY":
|
||||
return { ...state, showKey: action.value };
|
||||
case "OP_START":
|
||||
return { ...state, saving: true, testResult: null };
|
||||
case "OP_RESULT":
|
||||
return { ...state, testResult: action.result };
|
||||
case "OP_DONE":
|
||||
return { ...state, saving: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AIProviderPanel({ brandId }: Props) {
|
||||
const [provider, setProvider] = useState<AIProvider>("openai");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [orgId, setOrgId] = useState("");
|
||||
const [model, setModel] = useState("gpt-4o-mini");
|
||||
const [customEndpoint, setCustomEndpoint] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const {
|
||||
provider,
|
||||
apiKey,
|
||||
orgId,
|
||||
model,
|
||||
customEndpoint,
|
||||
loading,
|
||||
saving,
|
||||
testResult,
|
||||
showKey,
|
||||
} = state;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -114,20 +192,22 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
try {
|
||||
const data = await getAIProviderSettings(brandId);
|
||||
if (cancelled) return;
|
||||
setProvider((data.provider as AIProvider) ?? "openai");
|
||||
setApiKey(data.apiKey ?? "");
|
||||
setOrgId(data.orgId ?? "");
|
||||
setModel(data.model ?? "gpt-4o-mini");
|
||||
setCustomEndpoint(data.customEndpoint ?? "");
|
||||
dispatch({
|
||||
type: "HYDRATE",
|
||||
provider: (data.provider as AIProvider) ?? "openai",
|
||||
apiKey: data.apiKey ?? "",
|
||||
orgId: data.orgId ?? "",
|
||||
model: data.model ?? "gpt-4o-mini",
|
||||
customEndpoint: data.customEndpoint ?? "",
|
||||
});
|
||||
} catch {}
|
||||
if (!cancelled) setLoading(false);
|
||||
if (!cancelled) dispatch({ type: "HYDRATE_DONE" });
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [brandId]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setTestResult(null);
|
||||
dispatch({ type: "OP_START" });
|
||||
try {
|
||||
const res = await fetch("/api/integrations/ai-provider", {
|
||||
method: "POST",
|
||||
@@ -136,16 +216,18 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Save failed");
|
||||
setTestResult({ ok: true, message: "AI provider settings saved" });
|
||||
dispatch({ type: "OP_RESULT", result: { ok: true, message: "AI provider settings saved" } });
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Save failed" });
|
||||
dispatch({
|
||||
type: "OP_RESULT",
|
||||
result: { ok: false, message: err instanceof Error ? err.message : "Save failed" },
|
||||
});
|
||||
}
|
||||
setSaving(false);
|
||||
dispatch({ type: "OP_DONE" });
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTestResult(null);
|
||||
setSaving(true);
|
||||
dispatch({ type: "OP_START" });
|
||||
try {
|
||||
const res = await fetch("/api/integrations/ai-provider/test", {
|
||||
method: "POST",
|
||||
@@ -154,11 +236,17 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Connection failed");
|
||||
setTestResult({ ok: true, message: data.message ?? "Connection successful" });
|
||||
dispatch({
|
||||
type: "OP_RESULT",
|
||||
result: { ok: true, message: data.message ?? "Connection successful" },
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Test failed" });
|
||||
dispatch({
|
||||
type: "OP_RESULT",
|
||||
result: { ok: false, message: err instanceof Error ? err.message : "Test failed" },
|
||||
});
|
||||
}
|
||||
setSaving(false);
|
||||
dispatch({ type: "OP_DONE" });
|
||||
}
|
||||
|
||||
const models = provider === "custom" ? ["gpt-4o-mini"] : (PROVIDER_MODELS[provider] ?? []);
|
||||
@@ -196,8 +284,13 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
<button type="button"
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setProvider(p.id);
|
||||
setModel(p.id === "custom" ? "gpt-4o-mini" : PROVIDER_MODELS[p.id as Exclude<AIProvider, "custom">]?.[0] ?? "gpt-4o-mini");
|
||||
dispatch({
|
||||
type: "SET_PROVIDER",
|
||||
provider: p.id,
|
||||
model: p.id === "custom"
|
||||
? "gpt-4o-mini"
|
||||
: PROVIDER_MODELS[p.id as Exclude<AIProvider, "custom">]?.[0] ?? "gpt-4o-mini",
|
||||
});
|
||||
}}
|
||||
className={`rounded-xl p-3 text-center transition-all border-2 ${
|
||||
provider === p.id
|
||||
@@ -233,13 +326,13 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
<input id="fld-3-api-key" aria-label="Input"
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_API_KEY", value: e.target.value })}
|
||||
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : provider === "minimax" ? "ey..." : "sk-..."}
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
onClick={() => dispatch({ type: "SET_SHOW_KEY", value: !showKey })}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
@@ -252,7 +345,7 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
<input id="fld-1-organization-id-optional" aria-label="Org ..."
|
||||
type="text"
|
||||
value={orgId}
|
||||
onChange={(e) => setOrgId(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_ORG_ID", value: e.target.value })}
|
||||
placeholder="org-..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
@@ -281,11 +374,11 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
<input id="fld-4-api-key" aria-label="Sk ..."
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_API_KEY", value: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
|
||||
<button type="button" onClick={() => dispatch({ type: "SET_SHOW_KEY", value: !showKey })} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
|
||||
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -295,7 +388,7 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
<input id="fld-2-base-url" aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
|
||||
type="text"
|
||||
value={customEndpoint}
|
||||
onChange={(e) => setCustomEndpoint(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_CUSTOM_ENDPOINT", value: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
@@ -314,7 +407,7 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
{models.map((m) => (
|
||||
<button type="button"
|
||||
key={m}
|
||||
onClick={() => setModel(m)}
|
||||
onClick={() => dispatch({ type: "SET_MODEL", value: m })}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
model === m
|
||||
? "bg-violet-600 text-white"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useEffectEvent } from "react";
|
||||
import { useReducer, useCallback, useEffect, useRef, useEffectEvent } from "react";
|
||||
import { getAbandonedCarts, manuallyCloseAbandonedCart, resendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
|
||||
|
||||
type Props = { brandId: string };
|
||||
type Filter = "all" | "active" | "recovered";
|
||||
type Stats = { total: number; recovered: number; active: number; expired: number };
|
||||
|
||||
const STATUS_LABELS: Record<string, { en: string; color: string }> = {
|
||||
active: { en: "Active", color: "bg-blue-50 text-blue-700 border border-blue-200" },
|
||||
@@ -19,6 +21,59 @@ const STEP_LABELS: Record<number, string> = {
|
||||
3: "Email 3 (48h)",
|
||||
};
|
||||
|
||||
type State = {
|
||||
carts: AbandonedCart[];
|
||||
stats: Stats;
|
||||
loading: boolean;
|
||||
filter: Filter;
|
||||
page: number;
|
||||
selectedCart: AbandonedCart | null;
|
||||
closing: boolean;
|
||||
resending: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "LOAD_START" }
|
||||
| { type: "LOAD_SUCCESS"; carts: AbandonedCart[]; stats: Stats }
|
||||
| { type: "LOAD_END" }
|
||||
| { type: "SET_FILTER"; filter: Filter }
|
||||
| { type: "SET_PAGE"; page: number }
|
||||
| { type: "SELECT_CART"; cart: AbandonedCart | null }
|
||||
| { type: "SET_CLOSING"; value: boolean }
|
||||
| { type: "SET_RESENDING"; value: string | null };
|
||||
|
||||
const initialState: State = {
|
||||
carts: [],
|
||||
stats: { total: 0, recovered: 0, active: 0, expired: 0 },
|
||||
loading: false,
|
||||
filter: "all",
|
||||
page: 0,
|
||||
selectedCart: null,
|
||||
closing: false,
|
||||
resending: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "LOAD_START":
|
||||
return { ...state, loading: true };
|
||||
case "LOAD_SUCCESS":
|
||||
return { ...state, carts: action.carts, stats: action.stats };
|
||||
case "LOAD_END":
|
||||
return { ...state, loading: false };
|
||||
case "SET_FILTER":
|
||||
return { ...state, filter: action.filter, page: 0 };
|
||||
case "SET_PAGE":
|
||||
return { ...state, page: action.page };
|
||||
case "SELECT_CART":
|
||||
return { ...state, selectedCart: action.cart };
|
||||
case "SET_CLOSING":
|
||||
return { ...state, closing: action.value };
|
||||
case "SET_RESENDING":
|
||||
return { ...state, resending: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
function CartItemRow({ item }: { item: { name: string; quantity: number; unit_price: number } }) {
|
||||
return (
|
||||
<tr className="border-t border-[var(--admin-border)]">
|
||||
@@ -30,35 +85,8 @@ function CartItemRow({ item }: { item: { name: string; quantity: number; unit_pr
|
||||
}
|
||||
|
||||
export default function AbandonedCartDashboard({ brandId }: Props) {
|
||||
const [carts, setCarts] = useState<AbandonedCart[]>([]);
|
||||
const [stats, setStats] = useState({ total: 0, recovered: 0, active: 0, expired: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
type Filter = "all" | "active" | "recovered";
|
||||
const [view, setView] = useState<{ filter: Filter; page: number }>({ filter: "all", page: 0 });
|
||||
const filter = view.filter;
|
||||
const page = view.page;
|
||||
const setFilter = (filter: Filter) => setView({ filter, page: 0 });
|
||||
const PAGE_SIZE = 25;
|
||||
const [selectedCart, setSelectedCart] = useState<AbandonedCart | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [resending, setResending] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const result = await getAbandonedCarts(brandId);
|
||||
if (result.success) {
|
||||
setCarts(result.carts);
|
||||
setStats(result.stats);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [brandId]);
|
||||
|
||||
const handleClose = async (cartId: string) => {
|
||||
setClosing(true);
|
||||
await manuallyCloseAbandonedCart(cartId, brandId);
|
||||
await load();
|
||||
setClosing(false);
|
||||
};
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { carts, stats, loading, filter, page, selectedCart, closing, resending } = state;
|
||||
|
||||
const filteredCarts = carts.filter(c => {
|
||||
if (filter === "active") return c.status === "active";
|
||||
@@ -66,63 +94,45 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
|
||||
return true;
|
||||
});
|
||||
|
||||
const paginatedCarts = filteredCarts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const totalPages = Math.ceil(filteredCarts.length / PAGE_SIZE);
|
||||
const load = useCallback(async () => {
|
||||
dispatch({ type: "LOAD_START" });
|
||||
const result = await getAbandonedCarts(brandId);
|
||||
if (result.success) {
|
||||
dispatch({ type: "LOAD_SUCCESS", carts: result.carts, stats: result.stats });
|
||||
}
|
||||
dispatch({ type: "LOAD_END" });
|
||||
}, [brandId]);
|
||||
|
||||
const handleResend = async (cartId: string) => {
|
||||
setResending(cartId);
|
||||
await resendAbandonedCartEmail(cartId, brandId);
|
||||
const handleClose = async (cartId: string) => {
|
||||
dispatch({ type: "SET_CLOSING", value: true });
|
||||
await manuallyCloseAbandonedCart(cartId, brandId);
|
||||
await load();
|
||||
setResending(null);
|
||||
dispatch({ type: "SET_CLOSING", value: false });
|
||||
};
|
||||
|
||||
const cart = selectedCart;
|
||||
const handleResend = async (cartId: string) => {
|
||||
dispatch({ type: "SET_RESENDING", value: cartId });
|
||||
await resendAbandonedCartEmail(cartId, brandId);
|
||||
await load();
|
||||
dispatch({ type: "SET_RESENDING", value: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total", value: stats.total, color: "text-[var(--admin-text-primary)]" },
|
||||
{ label: "Active", value: stats.active, color: "text-blue-600" },
|
||||
{ label: "Recovered", value: stats.recovered, color: "text-green-600" },
|
||||
{ label: "Expired", value: stats.expired, color: "text-[var(--admin-text-muted)]" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white border border-[var(--admin-border)] rounded-2xl p-4 text-center">
|
||||
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<StatsGrid stats={stats} />
|
||||
|
||||
{/* Filter + refresh + pagination */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={load} disabled={loading}
|
||||
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
|
||||
{loading ? "Loading..." : "↻ Refresh"}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
{(["all", "active", "recovered"] as const).map(f => (
|
||||
<button type="button" key={f} onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button type="button" onClick={() => setView(v => ({ ...v, page: Math.max(0, v.page - 1) }))} disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Previous">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
|
||||
<button type="button" onClick={() => setView(v => ({ ...v, page: Math.min(totalPages - 1, v.page + 1) }))} disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Next">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FilterBar
|
||||
loading={loading}
|
||||
filter={filter}
|
||||
page={page}
|
||||
filteredCount={filteredCarts.length}
|
||||
onLoad={load}
|
||||
onSelectFilter={(f) => dispatch({ type: "SET_FILTER", filter: f })}
|
||||
onPrevPage={() => dispatch({ type: "SET_PAGE", page: Math.max(0, page - 1) })}
|
||||
onNextPage={(tp) => dispatch({ type: "SET_PAGE", page: Math.min(tp - 1, page + 1) })}
|
||||
/>
|
||||
|
||||
{/* Carts table */}
|
||||
{filteredCarts.length === 0 ? (
|
||||
@@ -130,67 +140,178 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
|
||||
<p className="text-[var(--admin-text-muted)] text-sm">No abandoned carts{filter !== "all" ? ` (${filter})` : ""}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-[var(--admin-border)] rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-5 py-3 font-medium">Contact</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Items</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Total</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Step</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Detected</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedCarts.map(c => {
|
||||
const meta = STATUS_LABELS[c.status] ?? STATUS_LABELS.active;
|
||||
return (
|
||||
<tr key={c.id} className="border-t border-[var(--admin-border)] hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div>
|
||||
<p className="text-[var(--admin-text-primary)] font-medium text-sm">{c.contact_name ?? "—"}</p>
|
||||
<p className="text-[var(--admin-text-muted)] text-xs">{c.contact_email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{c.cart_snapshot.item_count} items</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-semibold text-sm">${Number(c.cart_snapshot.subtotal).toFixed(2)}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">{STEP_LABELS[c.sequence_step] ?? c.sequence_step}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
|
||||
{meta.en}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(c.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={() => setSelectedCart(c)}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] px-2 py-1 rounded-lg hover:bg-stone-100 transition-all" aria-label="View">View</button>
|
||||
{c.status === "active" && (
|
||||
<button type="button" onClick={() => handleClose(c.id)} disabled={closing}
|
||||
className="text-xs text-amber-600 hover:text-amber-700 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all" aria-label="Close">Close</button>
|
||||
)}
|
||||
{c.status === "active" && (
|
||||
<button type="button" onClick={() => handleResend(c.id)} disabled={resending === c.id}
|
||||
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
|
||||
{resending === c.id ? "..." : "Resend"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<CartsTable
|
||||
carts={filteredCarts}
|
||||
page={page}
|
||||
closing={closing}
|
||||
resending={resending}
|
||||
onSelect={(c) => dispatch({ type: "SELECT_CART", cart: c })}
|
||||
onClose={handleClose}
|
||||
onResend={handleResend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cart detail modal */}
|
||||
<CartDetailModal cart={cart} onClose={() => setSelectedCart(null)} />
|
||||
<CartDetailModal
|
||||
cart={selectedCart}
|
||||
onClose={() => dispatch({ type: "SELECT_CART", cart: null })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsGrid({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total", value: stats.total, color: "text-[var(--admin-text-primary)]" },
|
||||
{ label: "Active", value: stats.active, color: "text-blue-600" },
|
||||
{ label: "Recovered", value: stats.recovered, color: "text-green-600" },
|
||||
{ label: "Expired", value: stats.expired, color: "text-[var(--admin-text-muted)]" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white border border-[var(--admin-border)] rounded-2xl p-4 text-center">
|
||||
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilterBarProps = {
|
||||
loading: boolean;
|
||||
filter: Filter;
|
||||
page: number;
|
||||
filteredCount: number;
|
||||
onLoad: () => void;
|
||||
onSelectFilter: (f: Filter) => void;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: (totalPages: number) => void;
|
||||
};
|
||||
|
||||
function FilterBar({
|
||||
loading,
|
||||
filter,
|
||||
page,
|
||||
filteredCount,
|
||||
onLoad,
|
||||
onSelectFilter,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
}: FilterBarProps) {
|
||||
const PAGE_SIZE = 25;
|
||||
const totalPages = Math.ceil(filteredCount / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={onLoad} disabled={loading}
|
||||
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
|
||||
{loading ? "Loading..." : "↻ Refresh"}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
{(["all", "active", "recovered"] as const).map(f => (
|
||||
<button type="button" key={f} onClick={() => onSelectFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button type="button" onClick={onPrevPage} disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Previous">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
|
||||
<button type="button" onClick={() => onNextPage(totalPages)} disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Next">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CartsTableProps = {
|
||||
carts: AbandonedCart[];
|
||||
page: number;
|
||||
closing: boolean;
|
||||
resending: string | null;
|
||||
onSelect: (c: AbandonedCart) => void;
|
||||
onClose: (id: string) => void;
|
||||
onResend: (id: string) => void;
|
||||
};
|
||||
|
||||
function CartsTable({
|
||||
carts,
|
||||
page,
|
||||
closing,
|
||||
resending,
|
||||
onSelect,
|
||||
onClose,
|
||||
onResend,
|
||||
}: CartsTableProps) {
|
||||
const PAGE_SIZE = 25;
|
||||
const paginatedCarts = carts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[var(--admin-border)] rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-5 py-3 font-medium">Contact</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Items</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Total</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Step</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Detected</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedCarts.map(c => {
|
||||
const meta = STATUS_LABELS[c.status] ?? STATUS_LABELS.active;
|
||||
return (
|
||||
<tr key={c.id} className="border-t border-[var(--admin-border)] hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div>
|
||||
<p className="text-[var(--admin-text-primary)] font-medium text-sm">{c.contact_name ?? "—"}</p>
|
||||
<p className="text-[var(--admin-text-muted)] text-xs">{c.contact_email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{c.cart_snapshot.item_count} items</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-semibold text-sm">${Number(c.cart_snapshot.subtotal).toFixed(2)}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">{STEP_LABELS[c.sequence_step] ?? c.sequence_step}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
|
||||
{meta.en}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(c.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={() => onSelect(c)}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] px-2 py-1 rounded-lg hover:bg-stone-100 transition-all" aria-label="View">View</button>
|
||||
{c.status === "active" && (
|
||||
<button type="button" onClick={() => onClose(c.id)} disabled={closing}
|
||||
className="text-xs text-amber-600 hover:text-amber-700 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all" aria-label="Close">Close</button>
|
||||
)}
|
||||
{c.status === "active" && (
|
||||
<button type="button" onClick={() => onResend(c.id)} disabled={resending === c.id}
|
||||
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
|
||||
{resending === c.id ? "..." : "Resend"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useEffectEvent, useRef, useCallback, useMemo, KeyboardEvent, ComponentType } from "react";
|
||||
import { useEffect, useEffectEvent, useRef, useCallback, useMemo, useReducer, KeyboardEvent, ComponentType } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -140,6 +140,33 @@ type SidebarProps = {
|
||||
enabledAddons?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
// ---- useReducer state ----
|
||||
type State = {
|
||||
mobileOpen: boolean;
|
||||
isClosing: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "OPEN_MOBILE" }
|
||||
| { type: "START_CLOSING" }
|
||||
| { type: "FINISH_CLOSING" };
|
||||
|
||||
const initialState: State = {
|
||||
mobileOpen: false,
|
||||
isClosing: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "OPEN_MOBILE":
|
||||
return { ...state, mobileOpen: true, isClosing: false };
|
||||
case "START_CLOSING":
|
||||
return { ...state, isClosing: true };
|
||||
case "FINISH_CLOSING":
|
||||
return { ...state, mobileOpen: false, isClosing: false };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminSidebar({
|
||||
userRole,
|
||||
brandIds,
|
||||
@@ -149,8 +176,8 @@ export default function AdminSidebar({
|
||||
}: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { mobileOpen, isClosing } = state;
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -210,13 +237,16 @@ export default function AdminSidebar({
|
||||
|
||||
// Close mobile menu with animation
|
||||
const closeMobileMenu = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
dispatch({ type: "START_CLOSING" });
|
||||
setTimeout(() => {
|
||||
setMobileOpen(false);
|
||||
setIsClosing(false);
|
||||
dispatch({ type: "FINISH_CLOSING" });
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const openMobileMenu = useCallback(() => {
|
||||
dispatch({ type: "OPEN_MOBILE" });
|
||||
}, []);
|
||||
|
||||
// Handle escape key
|
||||
// useEffectEvent so we always see the latest closeMobileMenu without
|
||||
// re-subscribing the keydown handler on every parent render.
|
||||
@@ -278,245 +308,398 @@ export default function AdminSidebar({
|
||||
[router, mobileOpen, closeMobileMenu, visibleItems],
|
||||
);
|
||||
|
||||
// Shared nav-link renderer. Used by both the desktop sidebar and the mobile
|
||||
// slide-in panel — they're the same list, just in a different container.
|
||||
const renderNavLink = (item: NavItemDef, index: number) => {
|
||||
const active = isActive(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
data-nav-index={index}
|
||||
onClick={() => closeMobileMenu()}
|
||||
onKeyDown={(e) => handleNavKeyDown(e, item.href, index)}
|
||||
className={[
|
||||
"group flex items-center gap-2.5 pl-3 pr-2.5 py-2 rounded-r-md text-xs font-medium",
|
||||
"border-l-[3px] transition-all duration-200",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
|
||||
].join(" ")}
|
||||
style={
|
||||
active
|
||||
? {
|
||||
backgroundColor: "var(--admin-sidebar-active)",
|
||||
color: "#F4F1E8",
|
||||
borderLeftColor: "var(--admin-sidebar-accent)",
|
||||
}
|
||||
: {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderLeftColor: "transparent",
|
||||
}
|
||||
}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--admin-sidebar-hover)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}
|
||||
}}
|
||||
aria-current={active ? "page" : undefined}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 transition-colors duration-200"
|
||||
style={{
|
||||
color: active
|
||||
? "var(--admin-sidebar-accent)"
|
||||
: "rgba(208, 203, 180, 0.6)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{active && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile hamburger button - accessible */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="fixed top-4 left-4 z-50 lg:hidden rounded-xl bg-white shadow-lg border border-[var(--admin-border)] p-3 transition-transform hover:scale-105 active:scale-95"
|
||||
aria-label="Open navigation menu"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="admin-sidebar"
|
||||
type="button"
|
||||
>
|
||||
<Menu className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{/* Mobile overlay backdrop with blur */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-200"
|
||||
style={{ opacity: isClosing ? 0 : 1 }}
|
||||
onClick={closeMobileMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar panel - dark canvas, grouped nav, amber accent for active */}
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
id="admin-sidebar"
|
||||
className={[
|
||||
"fixed top-0 left-0 h-full w-60 z-50",
|
||||
"border-r flex flex-col",
|
||||
"transition-transform duration-300 ease-out lg:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
isClosing ? "opacity-90" : "opacity-100",
|
||||
].join(" ")}
|
||||
style={{
|
||||
backgroundColor: "var(--admin-sidebar-bg)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
role="navigation"
|
||||
aria-label="Admin navigation"
|
||||
>
|
||||
{/* Logo row with close button on mobile */}
|
||||
<div
|
||||
className="flex items-center h-16 px-5 border-b flex-shrink-0"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => closeMobileMenu()}
|
||||
className="flex items-center gap-3 text-white hover:opacity-90 transition-opacity"
|
||||
aria-label="Admin Dashboard home"
|
||||
>
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
|
||||
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight">Admin</span>
|
||||
</Link>
|
||||
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={closeMobileMenu}
|
||||
className="lg:hidden p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Close navigation menu"
|
||||
type="button"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back to site link */}
|
||||
<div
|
||||
className="px-5 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
|
||||
style={{ color: "var(--admin-sidebar-text)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
Back to Site
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav groups with keyboard navigation */}
|
||||
<nav
|
||||
ref={mobileMenuRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2 scrollbar-thin"
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
<SideNavGroup key={group.label} label={group.label}>
|
||||
{group.items.map((item) => {
|
||||
const idx = visibleItems.findIndex((v) => v.href === item.href);
|
||||
return renderNavLink(item, idx);
|
||||
})}
|
||||
</SideNavGroup>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Bottom: command-palette tip + brand picker + role + sign out */}
|
||||
<div
|
||||
className="px-4 py-4 border-t flex-shrink-0 space-y-3"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
{/* Cmd+K hint — the command palette itself is mounted separately
|
||||
in the admin layout; this is just a discoverability nudge. */}
|
||||
<div
|
||||
className="flex items-center justify-between text-[10px] uppercase tracking-widest"
|
||||
style={{ color: "rgba(195, 195, 193, 0.5)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>Tip</span>
|
||||
<kbd
|
||||
className="font-mono px-1.5 py-0.5 rounded border"
|
||||
style={{
|
||||
color: "rgba(195, 195, 193, 0.7)",
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Brand selector — only show when admin has access to brands */}
|
||||
{brands && brands.length > 0 && (
|
||||
<BrandSelector
|
||||
brands={brands}
|
||||
activeBrandId={activeBrandId ?? null}
|
||||
showAllBrandsOption={userRole === "platform_admin"}
|
||||
isMultiBrandAdmin={(brandIds?.length ?? 0) > 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roleLabel && (
|
||||
<div
|
||||
className="px-3 py-2.5 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-[10px] font-medium uppercase tracking-widest mb-0.5"
|
||||
style={{ color: "rgba(195, 195, 193, 0.6)" }}
|
||||
>
|
||||
{roleLabel}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>
|
||||
Signed in
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 hover:bg-white/10"
|
||||
style={{ color: "rgba(195, 195, 193, 0.7)" }}
|
||||
aria-label="Sign out of admin"
|
||||
type="button"
|
||||
>
|
||||
<LogOut className="w-4 h-4" aria-hidden="true" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<MobileMenuButton mobileOpen={mobileOpen} onOpen={openMobileMenu} />
|
||||
<MobileBackdrop mobileOpen={mobileOpen} isClosing={isClosing} onClose={closeMobileMenu} />
|
||||
<SidebarPanel
|
||||
sidebarRef={sidebarRef}
|
||||
mobileMenuRef={mobileMenuRef}
|
||||
closeButtonRef={closeButtonRef}
|
||||
mobileOpen={mobileOpen}
|
||||
isClosing={isClosing}
|
||||
roleLabel={roleLabel}
|
||||
brands={brands}
|
||||
activeBrandId={activeBrandId}
|
||||
brandIds={brandIds}
|
||||
userRole={userRole}
|
||||
visibleGroups={visibleGroups}
|
||||
visibleItems={visibleItems}
|
||||
isActive={isActive}
|
||||
handleNavKeyDown={handleNavKeyDown}
|
||||
onCloseMobileMenu={closeMobileMenu}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileMenuButtonProps = {
|
||||
mobileOpen: boolean;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
function MobileMenuButton({ mobileOpen, onOpen }: MobileMenuButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="fixed top-4 left-4 z-50 lg:hidden rounded-xl bg-white shadow-lg border border-[var(--admin-border)] p-3 transition-transform hover:scale-105 active:scale-95"
|
||||
aria-label="Open navigation menu"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="admin-sidebar"
|
||||
type="button"
|
||||
>
|
||||
<Menu className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileBackdropProps = {
|
||||
mobileOpen: boolean;
|
||||
isClosing: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function MobileBackdrop({ mobileOpen, isClosing, onClose }: MobileBackdropProps) {
|
||||
if (!mobileOpen) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-200"
|
||||
style={{ opacity: isClosing ? 0 : 1 }}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarPanelProps = {
|
||||
sidebarRef: React.RefObject<HTMLDivElement | null>;
|
||||
mobileMenuRef: React.RefObject<HTMLDivElement | null>;
|
||||
closeButtonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
mobileOpen: boolean;
|
||||
isClosing: boolean;
|
||||
roleLabel: string | null;
|
||||
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
|
||||
activeBrandId?: string | null;
|
||||
brandIds?: string[];
|
||||
userRole?: string | null;
|
||||
visibleGroups: NavGroup[];
|
||||
visibleItems: NavItemDef[];
|
||||
isActive: (href: string) => boolean;
|
||||
handleNavKeyDown: (e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => void;
|
||||
onCloseMobileMenu: () => void;
|
||||
};
|
||||
|
||||
function SidebarPanel({
|
||||
sidebarRef,
|
||||
mobileMenuRef,
|
||||
closeButtonRef,
|
||||
mobileOpen,
|
||||
isClosing,
|
||||
roleLabel,
|
||||
brands,
|
||||
activeBrandId,
|
||||
brandIds,
|
||||
userRole,
|
||||
visibleGroups,
|
||||
visibleItems,
|
||||
isActive,
|
||||
handleNavKeyDown,
|
||||
onCloseMobileMenu,
|
||||
}: SidebarPanelProps) {
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
id="admin-sidebar"
|
||||
className={[
|
||||
"fixed top-0 left-0 h-full w-60 z-50",
|
||||
"border-r flex flex-col",
|
||||
"transition-transform duration-300 ease-out lg:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
isClosing ? "opacity-90" : "opacity-100",
|
||||
].join(" ")}
|
||||
style={{
|
||||
backgroundColor: "var(--admin-sidebar-bg)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
role="navigation"
|
||||
aria-label="Admin navigation"
|
||||
>
|
||||
<SidebarHeader closeButtonRef={closeButtonRef} onCloseMobileMenu={onCloseMobileMenu} />
|
||||
<BackToSiteLink />
|
||||
<NavGroups
|
||||
mobileMenuRef={mobileMenuRef}
|
||||
visibleGroups={visibleGroups}
|
||||
visibleItems={visibleItems}
|
||||
isActive={isActive}
|
||||
handleNavKeyDown={handleNavKeyDown}
|
||||
onCloseMobileMenu={onCloseMobileMenu}
|
||||
/>
|
||||
<SidebarFooter
|
||||
roleLabel={roleLabel}
|
||||
brands={brands}
|
||||
activeBrandId={activeBrandId}
|
||||
brandIds={brandIds}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarHeaderProps = {
|
||||
closeButtonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
onCloseMobileMenu: () => void;
|
||||
};
|
||||
|
||||
function SidebarHeader({ closeButtonRef, onCloseMobileMenu }: SidebarHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center h-16 px-5 border-b flex-shrink-0"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={onCloseMobileMenu}
|
||||
className="flex items-center gap-3 text-white hover:opacity-90 transition-opacity"
|
||||
aria-label="Admin Dashboard home"
|
||||
>
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
|
||||
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight">Admin</span>
|
||||
</Link>
|
||||
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onCloseMobileMenu}
|
||||
className="lg:hidden p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Close navigation menu"
|
||||
type="button"
|
||||
>
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToSiteLink() {
|
||||
return (
|
||||
<div
|
||||
className="px-5 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
|
||||
style={{ color: "var(--admin-sidebar-text)" }}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
Back to Site
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type NavGroupsProps = {
|
||||
mobileMenuRef: React.RefObject<HTMLDivElement | null>;
|
||||
visibleGroups: NavGroup[];
|
||||
visibleItems: NavItemDef[];
|
||||
isActive: (href: string) => boolean;
|
||||
handleNavKeyDown: (e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => void;
|
||||
onCloseMobileMenu: () => void;
|
||||
};
|
||||
|
||||
function NavGroups({
|
||||
mobileMenuRef,
|
||||
visibleGroups,
|
||||
visibleItems,
|
||||
isActive,
|
||||
handleNavKeyDown,
|
||||
onCloseMobileMenu,
|
||||
}: NavGroupsProps) {
|
||||
return (
|
||||
<nav
|
||||
ref={mobileMenuRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2 scrollbar-thin"
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
<SideNavGroup key={group.label} label={group.label}>
|
||||
{group.items.map((item) => {
|
||||
const idx = visibleItems.findIndex((v) => v.href === item.href);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
index={idx}
|
||||
active={isActive(item.href)}
|
||||
onKeyDown={(e) => handleNavKeyDown(e, item.href, idx)}
|
||||
onClick={onCloseMobileMenu}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SideNavGroup>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
type NavLinkProps = {
|
||||
item: NavItemDef;
|
||||
index: number;
|
||||
active: boolean;
|
||||
onKeyDown: (e: KeyboardEvent<HTMLAnchorElement>) => void;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function NavLink({ item, index, active, onKeyDown, onClick }: NavLinkProps) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={item.href}
|
||||
data-nav-index={index}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className={[
|
||||
"group flex items-center gap-2.5 pl-3 pr-2.5 py-2 rounded-r-md text-xs font-medium",
|
||||
"border-l-[3px] transition-all duration-200",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
|
||||
].join(" ")}
|
||||
style={
|
||||
active
|
||||
? {
|
||||
backgroundColor: "var(--admin-sidebar-active)",
|
||||
color: "#F4F1E8",
|
||||
borderLeftColor: "var(--admin-sidebar-accent)",
|
||||
}
|
||||
: {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderLeftColor: "transparent",
|
||||
}
|
||||
}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--admin-sidebar-hover)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}
|
||||
}}
|
||||
aria-current={active ? "page" : undefined}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 transition-colors duration-200"
|
||||
style={{
|
||||
color: active
|
||||
? "var(--admin-sidebar-accent)"
|
||||
: "rgba(208, 203, 180, 0.6)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{active && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarFooterProps = {
|
||||
roleLabel: string | null;
|
||||
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
|
||||
activeBrandId?: string | null;
|
||||
brandIds?: string[];
|
||||
userRole?: string | null;
|
||||
};
|
||||
|
||||
function SidebarFooter({ roleLabel, brands, activeBrandId, brandIds, userRole }: SidebarFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-4 border-t flex-shrink-0 space-y-3"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
{/* Cmd+K hint — the command palette itself is mounted separately
|
||||
in the admin layout; this is just a discoverability nudge. */}
|
||||
<CommandPaletteHint />
|
||||
{brands && brands.length > 0 && (
|
||||
<BrandSelector
|
||||
brands={brands}
|
||||
activeBrandId={activeBrandId ?? null}
|
||||
showAllBrandsOption={userRole === "platform_admin"}
|
||||
isMultiBrandAdmin={(brandIds?.length ?? 0) > 1}
|
||||
/>
|
||||
)}
|
||||
{roleLabel && <RoleBadge roleLabel={roleLabel} />}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 hover:bg-white/10"
|
||||
style={{ color: "rgba(195, 195, 193, 0.7)" }}
|
||||
aria-label="Sign out of admin"
|
||||
type="button"
|
||||
>
|
||||
<LogOut className="w-4 h-4" aria-hidden="true" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPaletteHint() {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between text-[10px] uppercase tracking-widest"
|
||||
style={{ color: "rgba(195, 195, 193, 0.5)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>Tip</span>
|
||||
<kbd
|
||||
className="font-mono px-1.5 py-0.5 rounded border"
|
||||
style={{
|
||||
color: "rgba(195, 195, 193, 0.7)",
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleBadge({ roleLabel }: { roleLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-2.5 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-[10px] font-medium uppercase tracking-widest mb-0.5"
|
||||
style={{ color: "rgba(195, 195, 193, 0.6)" }}
|
||||
>
|
||||
{roleLabel}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>
|
||||
Signed in
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Admin Analytics Dashboard - Real metrics and business insights
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useEffectEvent, useCallback } from "react";
|
||||
import { useEffect, useEffectEvent, useCallback, useReducer } from "react";
|
||||
import Link from "next/link";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
@@ -69,47 +69,19 @@ interface RevenueChartProps {
|
||||
}
|
||||
|
||||
function RevenueChart({ data, totalRevenue, avgOrder }: RevenueChartProps) {
|
||||
const [period, setPeriod] = useState<"7" | "30" | "90" | "365">("30");
|
||||
|
||||
const maxRevenue = Math.max(...data.map(d => d.revenue), 1);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Revenue Overview</h2>
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
className={`px-3 py-1 text-sm rounded-lg ${period === "7" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
|
||||
onClick={() => setPeriod("7")}
|
||||
>
|
||||
7D
|
||||
</button>
|
||||
<button type="button"
|
||||
className={`px-3 py-1 text-sm rounded-lg ${period === "30" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
|
||||
onClick={() => setPeriod("30")}
|
||||
>
|
||||
30D
|
||||
</button>
|
||||
<button type="button"
|
||||
className={`px-3 py-1 text-sm rounded-lg ${period === "90" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
|
||||
onClick={() => setPeriod("90")}
|
||||
>
|
||||
90D
|
||||
</button>
|
||||
<button type="button"
|
||||
className={`px-3 py-1 text-sm rounded-lg ${period === "365" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
|
||||
onClick={() => setPeriod("365")}
|
||||
>
|
||||
1Y
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{data.length > 0 ? (
|
||||
<>
|
||||
<div className="h-64 flex items-end justify-between gap-4">
|
||||
{data.map((item, i) => (
|
||||
<div key={`${item.date}-${item.revenue}`} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
<div
|
||||
className="w-full bg-gradient-to-t from-primary/20 to-primary rounded-t-lg transition-all hover:from-primary/30"
|
||||
style={{ height: `${Math.max((item.revenue / maxRevenue) * 100, 5)}%` }}
|
||||
/>
|
||||
@@ -181,7 +153,7 @@ function TopProductsTable({ products }: TopProductsTableProps) {
|
||||
<td className="py-4 text-right font-medium text-gray-900">${product.revenue.toFixed(2)}</td>
|
||||
<td className="py-4 text-right">
|
||||
<span className={`inline-flex items-center gap-1 text-sm ${
|
||||
getTrend(product.avg_price) === "up" ? "text-green-600" :
|
||||
getTrend(product.avg_price) === "up" ? "text-green-600" :
|
||||
getTrend(product.avg_price) === "down" ? "text-red-600" : "text-gray-600"
|
||||
}`}>
|
||||
{getTrend(product.avg_price) === "up" ? "↑" : getTrend(product.avg_price) === "down" ? "↓" : "→"}
|
||||
@@ -268,9 +240,9 @@ function CustomerGrowthChart({ data }: CustomerGrowthChartProps) {
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle cx="64" cy="64" r="56" fill="none" stroke="#e5e7eb" strokeWidth="12" />
|
||||
<circle
|
||||
cx="64" cy="64" r="56" fill="none"
|
||||
stroke="#10b981" strokeWidth="12"
|
||||
<circle
|
||||
cx="64" cy="64" r="56" fill="none"
|
||||
stroke="#10b981" strokeWidth="12"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
className="transition-all duration-1000"
|
||||
@@ -316,7 +288,7 @@ function ConversionFunnel({ data }: ConversionFunnelProps) {
|
||||
<div key={`${step.stage}-${step.count}-${step.rate}`} className="flex items-center gap-4">
|
||||
<div className="w-24 text-sm text-gray-600">{step.stage}</div>
|
||||
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary to-primary/60 rounded-lg transition-all duration-500"
|
||||
style={{ width: `${Math.max(step.rate, 2)}%` }}
|
||||
/>
|
||||
@@ -335,35 +307,77 @@ function ConversionFunnel({ data }: ConversionFunnelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- useReducer state ----
|
||||
type State = {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
metrics: AnalyticsMetrics;
|
||||
revenueData: RevenueDataPoint[];
|
||||
topProducts: ProductPerformance[];
|
||||
recentOrders: RecentOrder[];
|
||||
customerGrowth: CustomerGrowth;
|
||||
conversionFunnel: ConversionFunnel[];
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "FETCH_START" }
|
||||
| { type: "FETCH_SUCCESS"; metrics: AnalyticsMetrics; revenueData: RevenueDataPoint[]; topProducts: ProductPerformance[]; recentOrders: RecentOrder[]; customerGrowth: CustomerGrowth; conversionFunnel: ConversionFunnel[] }
|
||||
| { type: "FETCH_ERROR"; error: string };
|
||||
|
||||
const initialMetrics: AnalyticsMetrics = {
|
||||
total_revenue: 0,
|
||||
revenue_change: 0,
|
||||
total_orders: 0,
|
||||
orders_change: 0,
|
||||
active_customers: 0,
|
||||
customers_change: 0,
|
||||
avg_order_value: 0,
|
||||
aov_change: 0,
|
||||
};
|
||||
|
||||
const initialCustomerGrowth: CustomerGrowth = {
|
||||
total_customers: 0,
|
||||
new_this_month: 0,
|
||||
retention_rate: 0,
|
||||
growth_rate: 0,
|
||||
};
|
||||
|
||||
const initialState: State = {
|
||||
isLoading: true,
|
||||
error: null,
|
||||
metrics: initialMetrics,
|
||||
revenueData: [],
|
||||
topProducts: [],
|
||||
recentOrders: [],
|
||||
customerGrowth: initialCustomerGrowth,
|
||||
conversionFunnel: [],
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "FETCH_START":
|
||||
return { ...state, isLoading: true, error: null };
|
||||
case "FETCH_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
metrics: action.metrics,
|
||||
revenueData: action.revenueData,
|
||||
topProducts: action.topProducts,
|
||||
recentOrders: action.recentOrders,
|
||||
customerGrowth: action.customerGrowth,
|
||||
conversionFunnel: action.conversionFunnel,
|
||||
};
|
||||
case "FETCH_ERROR":
|
||||
return { ...state, isLoading: false, error: action.error };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [metrics, setMetrics] = useState<AnalyticsMetrics>({
|
||||
total_revenue: 0,
|
||||
revenue_change: 0,
|
||||
total_orders: 0,
|
||||
orders_change: 0,
|
||||
active_customers: 0,
|
||||
customers_change: 0,
|
||||
avg_order_value: 0,
|
||||
aov_change: 0,
|
||||
});
|
||||
|
||||
const [revenueData, setRevenueData] = useState<RevenueDataPoint[]>([]);
|
||||
const [topProducts, setTopProducts] = useState<ProductPerformance[]>([]);
|
||||
const [recentOrders, setRecentOrders] = useState<RecentOrder[]>([]);
|
||||
const [customerGrowth, setCustomerGrowth] = useState<CustomerGrowth>({
|
||||
total_customers: 0,
|
||||
new_this_month: 0,
|
||||
retention_rate: 0,
|
||||
growth_rate: 0,
|
||||
});
|
||||
const [conversionFunnel, setConversionFunnel] = useState<ConversionFunnel[]>([]);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const fetchAllData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
dispatch({ type: "FETCH_START" });
|
||||
|
||||
try {
|
||||
const [
|
||||
@@ -382,17 +396,18 @@ export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
|
||||
getConversionFunnel(),
|
||||
]);
|
||||
|
||||
setMetrics(metricsData);
|
||||
setRevenueData(revenueChartData);
|
||||
setTopProducts(productsData);
|
||||
setRecentOrders(ordersData);
|
||||
setCustomerGrowth(growthData);
|
||||
setConversionFunnel(funnelData);
|
||||
dispatch({
|
||||
type: "FETCH_SUCCESS",
|
||||
metrics: metricsData,
|
||||
revenueData: revenueChartData,
|
||||
topProducts: productsData,
|
||||
recentOrders: ordersData,
|
||||
customerGrowth: growthData,
|
||||
conversionFunnel: funnelData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch analytics:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load analytics");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
dispatch({ type: "FETCH_ERROR", error: err instanceof Error ? err.message : "Failed to load analytics" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -409,119 +424,159 @@ export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
|
||||
});
|
||||
}, [brandId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
if (state.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button type="button"
|
||||
onClick={fetchAllData}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (state.error) {
|
||||
return <ErrorState error={state.error} onRetry={fetchAllData} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||
<p className="text-gray-500">Track your business performance and growth</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button"
|
||||
onClick={fetchAllData}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90">
|
||||
Export Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardHeader onRefresh={fetchAllData} />
|
||||
<MetricsRow metrics={state.metrics} />
|
||||
<ChartsRow
|
||||
revenueData={state.revenueData}
|
||||
totalRevenue={state.metrics.total_revenue}
|
||||
avgOrder={state.metrics.avg_order_value}
|
||||
customerGrowth={state.customerGrowth}
|
||||
/>
|
||||
<TablesRow topProducts={state.topProducts} recentOrders={state.recentOrders} />
|
||||
<ConversionFunnel data={state.conversionFunnel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<MetricCard
|
||||
title="Total Revenue"
|
||||
value={`$${metrics.total_revenue.toLocaleString()}`}
|
||||
change={metrics.revenue_change}
|
||||
trend={metrics.revenue_change >= 0 ? "up" : "down"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Orders"
|
||||
value={metrics.total_orders.toLocaleString()}
|
||||
change={metrics.orders_change}
|
||||
trend={metrics.orders_change >= 0 ? "up" : "down"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Customers"
|
||||
value={metrics.active_customers.toLocaleString()}
|
||||
change={metrics.customers_change}
|
||||
trend={metrics.customers_change >= 0 ? "up" : "down"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg. Order Value"
|
||||
value={`$${metrics.avg_order_value.toFixed(2)}`}
|
||||
change={metrics.aov_change}
|
||||
trend={metrics.aov_change >= 0 ? "up" : "down"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
}
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button type="button"
|
||||
onClick={onRetry}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardHeader({ onRefresh }: { onRefresh: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||
<p className="text-gray-500">Track your business performance and growth</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button"
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90">
|
||||
Export Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsRow({ metrics }: { metrics: AnalyticsMetrics }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<MetricCard
|
||||
title="Total Revenue"
|
||||
value={`$${metrics.total_revenue.toLocaleString()}`}
|
||||
change={metrics.revenue_change}
|
||||
trend={metrics.revenue_change >= 0 ? "up" : "down"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Orders"
|
||||
value={metrics.total_orders.toLocaleString()}
|
||||
change={metrics.orders_change}
|
||||
trend={metrics.orders_change >= 0 ? "up" : "down"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Customers"
|
||||
value={metrics.active_customers.toLocaleString()}
|
||||
change={metrics.customers_change}
|
||||
trend={metrics.customers_change >= 0 ? "up" : "down"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Avg. Order Value"
|
||||
value={`$${metrics.avg_order_value.toFixed(2)}`}
|
||||
change={metrics.aov_change}
|
||||
trend={metrics.aov_change >= 0 ? "up" : "down"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChartsRowProps = {
|
||||
revenueData: RevenueDataPoint[];
|
||||
totalRevenue: number;
|
||||
avgOrder: number;
|
||||
customerGrowth: CustomerGrowth;
|
||||
};
|
||||
|
||||
function ChartsRow({ revenueData, totalRevenue, avgOrder, customerGrowth }: ChartsRowProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<RevenueChart
|
||||
data={revenueData}
|
||||
totalRevenue={totalRevenue}
|
||||
avgOrder={avgOrder}
|
||||
/>
|
||||
</div>
|
||||
<CustomerGrowthChart data={customerGrowth} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<RevenueChart
|
||||
data={revenueData}
|
||||
totalRevenue={metrics.total_revenue}
|
||||
avgOrder={metrics.avg_order_value}
|
||||
/>
|
||||
</div>
|
||||
<CustomerGrowthChart data={customerGrowth} />
|
||||
</div>
|
||||
type TablesRowProps = {
|
||||
topProducts: ProductPerformance[];
|
||||
recentOrders: RecentOrder[];
|
||||
};
|
||||
|
||||
{/* Tables Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<TopProductsTable products={topProducts} />
|
||||
<RecentOrdersTable orders={recentOrders} />
|
||||
</div>
|
||||
|
||||
{/* Funnel */}
|
||||
<ConversionFunnel data={conversionFunnel} />
|
||||
function TablesRow({ topProducts, recentOrders }: TablesRowProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<TopProductsTable products={topProducts} />
|
||||
<RecentOrdersTable orders={recentOrders} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CommunicationSettings } from "@/actions/communications/settings";
|
||||
import { upsertCommunicationSettings } from "@/actions/communications/settings";
|
||||
@@ -31,6 +31,56 @@ const WarningIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
type State = {
|
||||
saving: boolean;
|
||||
senderEmail: string;
|
||||
senderName: string;
|
||||
replyTo: string;
|
||||
footerHtml: string;
|
||||
error: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_SAVING"; value: boolean }
|
||||
| { type: "SET_SENDER_EMAIL"; value: string }
|
||||
| { type: "SET_SENDER_NAME"; value: string }
|
||||
| { type: "SET_REPLY_TO"; value: string }
|
||||
| { type: "SET_FOOTER_HTML"; value: string }
|
||||
| { type: "SET_ERROR"; value: string }
|
||||
| { type: "SET_SUCCESS"; value: boolean };
|
||||
|
||||
function buildInitialState(settings: CommunicationSettings | null): State {
|
||||
return {
|
||||
saving: false,
|
||||
senderEmail: settings?.default_sender_email ?? "",
|
||||
senderName: settings?.default_sender_name ?? "",
|
||||
replyTo: settings?.reply_to_email ?? "",
|
||||
footerHtml: settings?.email_footer_html ?? "",
|
||||
error: "",
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_SAVING":
|
||||
return { ...state, saving: action.value };
|
||||
case "SET_SENDER_EMAIL":
|
||||
return { ...state, senderEmail: action.value };
|
||||
case "SET_SENDER_NAME":
|
||||
return { ...state, senderName: action.value };
|
||||
case "SET_REPLY_TO":
|
||||
return { ...state, replyTo: action.value };
|
||||
case "SET_FOOTER_HTML":
|
||||
return { ...state, footerHtml: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SET_SUCCESS":
|
||||
return { ...state, success: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function CommunicationSettingsForm({
|
||||
settings,
|
||||
brandId,
|
||||
@@ -39,32 +89,26 @@ export default function CommunicationSettingsForm({
|
||||
brandId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [senderEmail, setSenderEmail] = useState(settings?.default_sender_email ?? "");
|
||||
const [senderName, setSenderName] = useState(settings?.default_sender_name ?? "");
|
||||
const [replyTo, setReplyTo] = useState(settings?.reply_to_email ?? "");
|
||||
const [footerHtml, setFooterHtml] = useState(settings?.email_footer_html ?? "");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, undefined, () => buildInitialState(settings));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
dispatch({ type: "SET_SAVING", value: true });
|
||||
dispatch({ type: "SET_ERROR", value: "" });
|
||||
dispatch({ type: "SET_SUCCESS", value: false });
|
||||
const result = await upsertCommunicationSettings({
|
||||
brand_id: brandId,
|
||||
sender_email: senderEmail,
|
||||
sender_name: senderName,
|
||||
reply_to_email: replyTo,
|
||||
sender_email: state.senderEmail,
|
||||
sender_name: state.senderName,
|
||||
reply_to_email: state.replyTo,
|
||||
provider: "resend",
|
||||
footer_html: footerHtml,
|
||||
footer_html: state.footerHtml,
|
||||
});
|
||||
setSaving(false);
|
||||
dispatch({ type: "SET_SAVING", value: false });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
dispatch({ type: "SET_SUCCESS", value: true });
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
@@ -109,8 +153,8 @@ export default function CommunicationSettingsForm({
|
||||
<AdminInput label="Default Sender Email" helpText="The email address recipients will see as the sender">
|
||||
<AdminTextInput
|
||||
type="email"
|
||||
value={senderEmail}
|
||||
onChange={(e) => setSenderEmail(e.target.value)}
|
||||
value={state.senderEmail}
|
||||
onChange={(e) => dispatch({ type: "SET_SENDER_EMAIL", value: e.target.value })}
|
||||
placeholder="orders@yourbrand.com"
|
||||
/>
|
||||
</AdminInput>
|
||||
@@ -118,8 +162,8 @@ export default function CommunicationSettingsForm({
|
||||
<AdminInput label="Default Sender Name" helpText="The name displayed as the sender">
|
||||
<AdminTextInput
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
value={state.senderName}
|
||||
onChange={(e) => dispatch({ type: "SET_SENDER_NAME", value: e.target.value })}
|
||||
placeholder="Route Commerce"
|
||||
/>
|
||||
</AdminInput>
|
||||
@@ -127,16 +171,16 @@ export default function CommunicationSettingsForm({
|
||||
<AdminInput label="Reply-To Email" helpText="Emails will be replied to this address">
|
||||
<AdminTextInput
|
||||
type="email"
|
||||
value={replyTo}
|
||||
onChange={(e) => setReplyTo(e.target.value)}
|
||||
value={state.replyTo}
|
||||
onChange={(e) => dispatch({ type: "SET_REPLY_TO", value: e.target.value })}
|
||||
placeholder="support@yourbrand.com"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Email Footer HTML (optional)" helpText="Add {unsubscribe_url} as placeholder for automatic unsubscribe links">
|
||||
<AdminTextarea
|
||||
value={footerHtml}
|
||||
onChange={(e) => setFooterHtml(e.target.value)}
|
||||
value={state.footerHtml}
|
||||
onChange={(e) => dispatch({ type: "SET_FOOTER_HTML", value: e.target.value })}
|
||||
rows={4}
|
||||
placeholder={"<p>Unsubscribe: <a href='...'>link</a></p>"}
|
||||
/>
|
||||
@@ -146,10 +190,10 @@ export default function CommunicationSettingsForm({
|
||||
{/* Form footer with actions */}
|
||||
<div className="px-5 py-4 border-t border-[var(--admin-border)] bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-card)] flex items-center justify-between">
|
||||
<div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
{state.error && (
|
||||
<p className="text-sm text-red-500">{state.error}</p>
|
||||
)}
|
||||
{success && (
|
||||
{state.success && (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||
<CheckIcon />
|
||||
<span>Settings saved successfully</span>
|
||||
@@ -158,12 +202,12 @@ export default function CommunicationSettingsForm({
|
||||
</div>
|
||||
<AdminButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
disabled={state.saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
{state.saving ? "Saving..." : "Save Settings"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useReducer,
|
||||
useRef,
|
||||
useCallback,
|
||||
type Dispatch,
|
||||
@@ -26,7 +26,10 @@ type ImportResult = {
|
||||
errors: { row: ContactImportEntry; error: string }[];
|
||||
};
|
||||
|
||||
type ImportHistoryEntry = { filename: string; size: number; createdAt: string };
|
||||
|
||||
const LARGE_FILE_ROWS = 10_000;
|
||||
const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
email: "Email",
|
||||
@@ -192,78 +195,143 @@ function applyMappings(
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── State / reducer ───────────────────────────────────────────────────────────
|
||||
|
||||
type State = {
|
||||
step: Step;
|
||||
file: File | null;
|
||||
preview: ImportPreviewResult | null;
|
||||
importRows: ContactImportEntry[];
|
||||
result: ImportResult | null;
|
||||
globalError: string;
|
||||
overridingMappings: Record<string, ImportField>;
|
||||
uploadProgress: string;
|
||||
importHistory: ImportHistoryEntry[];
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_FILE"; file: File }
|
||||
| { type: "SET_PREVIEW"; preview: ImportPreviewResult }
|
||||
| { type: "SET_STEP"; step: Step }
|
||||
| { type: "SET_RESULT"; result: ImportResult | null }
|
||||
| { type: "SET_IMPORT_ROWS"; rows: ContactImportEntry[] }
|
||||
| { type: "SET_GLOBAL_ERROR"; value: string }
|
||||
| { type: "SET_OVERRIDE"; column: string; field: ImportField | null }
|
||||
| { type: "RESET_OVERRIDES" }
|
||||
| { type: "SET_UPLOAD_PROGRESS"; value: string }
|
||||
| { type: "SET_IMPORT_HISTORY"; history: ImportHistoryEntry[] }
|
||||
| { type: "RESET" };
|
||||
|
||||
const initialState: State = {
|
||||
step: "idle",
|
||||
file: null,
|
||||
preview: null,
|
||||
importRows: [],
|
||||
result: null,
|
||||
globalError: "",
|
||||
overridingMappings: {},
|
||||
uploadProgress: "",
|
||||
importHistory: [],
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_FILE":
|
||||
return { ...state, file: action.file };
|
||||
case "SET_PREVIEW":
|
||||
return { ...state, preview: action.preview };
|
||||
case "SET_STEP":
|
||||
return { ...state, step: action.step };
|
||||
case "SET_RESULT":
|
||||
return { ...state, result: action.result };
|
||||
case "SET_IMPORT_ROWS":
|
||||
return { ...state, importRows: action.rows };
|
||||
case "SET_GLOBAL_ERROR":
|
||||
return { ...state, globalError: action.value };
|
||||
case "SET_OVERRIDE":
|
||||
return {
|
||||
...state,
|
||||
overridingMappings: {
|
||||
...state.overridingMappings,
|
||||
[action.column]: action.field,
|
||||
},
|
||||
};
|
||||
case "RESET_OVERRIDES":
|
||||
return { ...state, overridingMappings: {} };
|
||||
case "SET_UPLOAD_PROGRESS":
|
||||
return { ...state, uploadProgress: action.value };
|
||||
case "SET_IMPORT_HISTORY":
|
||||
return { ...state, importHistory: action.history };
|
||||
case "RESET":
|
||||
return {
|
||||
...initialState,
|
||||
importHistory: state.importHistory,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
const [step, setStep] = useState<Step>("idle");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<ImportPreviewResult | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const rawHeadersRef = useRef<string[]>([]);
|
||||
const setRawHeaders = (v: string[]) => { rawHeadersRef.current = v; };
|
||||
const rawRowsRef = useRef<string[][]>([]);
|
||||
const setRawRows = (v: string[][]) => { rawRowsRef.current = v; };
|
||||
const [importRows, setImportRows] = useState<ContactImportEntry[]>([]);
|
||||
const importingRef = useRef(false);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [globalError, setGlobalError] = useState("");
|
||||
const [overridingMappings, setOverridingMappings] = useState<
|
||||
Record<string, ImportField>
|
||||
>({});
|
||||
const [uploadProgress, setUploadProgress] = useState<string>("");
|
||||
const [importHistory, setImportHistory] = useState<{ filename: string; size: number; createdAt: string }[]>([]);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// For large file detection (farmers with lots of data)
|
||||
const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// ── CSV parsing + preview ──────────────────────────────────────────────────
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (f: File) => {
|
||||
setFile(f);
|
||||
setResult(null);
|
||||
setGlobalError("");
|
||||
setOverridingMappings({});
|
||||
dispatch({ type: "SET_FILE", file: f });
|
||||
dispatch({ type: "SET_RESULT", result: null });
|
||||
dispatch({ type: "SET_GLOBAL_ERROR", value: "" });
|
||||
dispatch({ type: "RESET_OVERRIDES" });
|
||||
|
||||
// Check if file is large enough to warrant bucket upload
|
||||
const isLargeFile = f.size > LARGE_FILE_THRESHOLD;
|
||||
|
||||
if (isLargeFile) {
|
||||
// Use bucket upload for large files
|
||||
setUploadProgress("Uploading file to storage...");
|
||||
dispatch({ type: "SET_UPLOAD_PROGRESS", value: "Uploading file to storage..." });
|
||||
|
||||
const uploadResult = await uploadContactsToBucket(brandId, f);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
setGlobalError(uploadResult.error);
|
||||
setStep("idle");
|
||||
dispatch({ type: "SET_GLOBAL_ERROR", value: uploadResult.error });
|
||||
dispatch({ type: "SET_STEP", step: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadProgress(`Processing ${uploadResult.recordCount.toLocaleString()} records...`);
|
||||
dispatch({ type: "SET_UPLOAD_PROGRESS", value: `Processing ${uploadResult.recordCount.toLocaleString()} records...` });
|
||||
|
||||
// Process from bucket
|
||||
const processResult = await processBucketImport(brandId, uploadResult.fileUrl);
|
||||
|
||||
importingRef.current = false;
|
||||
setUploadProgress("");
|
||||
dispatch({ type: "SET_UPLOAD_PROGRESS", value: "" });
|
||||
|
||||
if (!processResult.success) {
|
||||
setGlobalError(processResult.error);
|
||||
setStep("idle");
|
||||
dispatch({ type: "SET_GLOBAL_ERROR", value: processResult.error });
|
||||
dispatch({ type: "SET_STEP", step: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
setResult({
|
||||
created: processResult.created,
|
||||
updated: processResult.updated,
|
||||
skipped: processResult.skipped,
|
||||
errors: [],
|
||||
dispatch({
|
||||
type: "SET_RESULT",
|
||||
result: {
|
||||
created: processResult.created,
|
||||
updated: processResult.updated,
|
||||
skipped: processResult.skipped,
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
setStep("result");
|
||||
dispatch({ type: "SET_STEP", step: "result" });
|
||||
|
||||
// Load import history
|
||||
const historyResult = await listImportHistory(brandId);
|
||||
if (historyResult.success) {
|
||||
setImportHistory(historyResult.imports);
|
||||
dispatch({ type: "SET_IMPORT_HISTORY", history: historyResult.imports });
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -274,21 +342,21 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
const previewResult = await previewContactImport(text);
|
||||
|
||||
if (!previewResult.success) {
|
||||
setGlobalError(previewResult.error);
|
||||
setStep("idle");
|
||||
dispatch({ type: "SET_GLOBAL_ERROR", value: previewResult.error });
|
||||
dispatch({ type: "SET_STEP", step: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { csv, totalRows } = await import("@/lib/csv-parser").then(
|
||||
const { csv } = await import("@/lib/csv-parser").then(
|
||||
(m) => m.parseCSVWithLimits(text)
|
||||
);
|
||||
|
||||
setRawHeaders(csv.headers);
|
||||
setRawRows(csv.rows);
|
||||
setPreview(previewResult.preview);
|
||||
setStep("preview");
|
||||
dispatch({ type: "SET_PREVIEW", preview: previewResult.preview });
|
||||
dispatch({ type: "SET_STEP", step: "preview" });
|
||||
},
|
||||
[brandId, LARGE_FILE_THRESHOLD]
|
||||
[brandId]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -296,7 +364,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f && f.name.endsWith(".csv")) handleFile(f);
|
||||
else setGlobalError("Please upload a .csv file");
|
||||
else dispatch({ type: "SET_GLOBAL_ERROR", value: "Please upload a .csv file" });
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
@@ -313,17 +381,17 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
async () => {
|
||||
if (!preview || rawRowsRef.current.length === 0) return;
|
||||
setStep("importing");
|
||||
if (!state.preview || rawRowsRef.current.length === 0) return;
|
||||
dispatch({ type: "SET_STEP", step: "importing" });
|
||||
|
||||
const rowsToImport = applyMappings(
|
||||
preview.mappings,
|
||||
overridingMappings,
|
||||
state.preview.mappings,
|
||||
state.overridingMappings,
|
||||
rawHeadersRef.current,
|
||||
rawRowsRef.current
|
||||
);
|
||||
|
||||
setImportRows(rowsToImport);
|
||||
dispatch({ type: "SET_IMPORT_ROWS", rows: rowsToImport });
|
||||
|
||||
importingRef.current = true;
|
||||
const importResult = await importContactsBatch({
|
||||
@@ -333,24 +401,18 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
importingRef.current = false;
|
||||
|
||||
if (importResult.success) {
|
||||
setResult(importResult.result);
|
||||
setStep("result");
|
||||
dispatch({ type: "SET_RESULT", result: importResult.result });
|
||||
dispatch({ type: "SET_STEP", step: "result" });
|
||||
} else {
|
||||
setGlobalError(importResult.error ?? "Import failed");
|
||||
setStep("result");
|
||||
dispatch({ type: "SET_GLOBAL_ERROR", value: importResult.error ?? "Import failed" });
|
||||
dispatch({ type: "SET_STEP", step: "result" });
|
||||
}
|
||||
},
|
||||
[brandId, preview, overridingMappings]
|
||||
[brandId, state.preview, state.overridingMappings]
|
||||
);
|
||||
|
||||
const handleReset = () => {
|
||||
setStep("idle");
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setImportRows([]);
|
||||
setResult(null);
|
||||
setGlobalError("");
|
||||
setOverridingMappings({});
|
||||
dispatch({ type: "RESET" });
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
@@ -358,250 +420,411 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded bg-[var(--admin-text-primary)]">
|
||||
{Icons.uploadCloud("w-3 h-3 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Import Contacts</h3>
|
||||
<ContactHeader
|
||||
showStartOver={state.step !== "idle"}
|
||||
onStartOver={handleReset}
|
||||
/>
|
||||
|
||||
{state.file && state.file.size > LARGE_FILE_THRESHOLD && (
|
||||
<LargeFileBanner />
|
||||
)}
|
||||
|
||||
{state.uploadProgress && <UploadProgress message={state.uploadProgress} />}
|
||||
|
||||
{state.globalError && <ErrorBanner message={state.globalError} />}
|
||||
|
||||
{state.step === "idle" && (
|
||||
<ContactUploadStep
|
||||
fileRef={fileRef}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.step === "preview" && state.preview && (
|
||||
<ContactPreviewStep
|
||||
fileName={state.file?.name ?? ""}
|
||||
preview={state.preview}
|
||||
overridingMappings={state.overridingMappings}
|
||||
setOverridingMappings={(updater) => {
|
||||
// Support both function and object updates
|
||||
if (typeof updater === "function") {
|
||||
// Compute new value, then dispatch each changed column
|
||||
const prev = state.overridingMappings;
|
||||
const next = updater(prev);
|
||||
for (const [col, field] of Object.entries(next)) {
|
||||
if (prev[col] !== field) {
|
||||
dispatch({ type: "SET_OVERRIDE", column: col, field });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Replace all overrides
|
||||
for (const [col, field] of Object.entries(state.overridingMappings)) {
|
||||
if (!(col in updater)) {
|
||||
dispatch({ type: "SET_OVERRIDE", column: col, field: null });
|
||||
}
|
||||
}
|
||||
for (const [col, field] of Object.entries(updater)) {
|
||||
if (state.overridingMappings[col] !== field) {
|
||||
dispatch({ type: "SET_OVERRIDE", column: col, field });
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={handleReset}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.step === "importing" && (
|
||||
<ImportingStep count={state.importRows.length} />
|
||||
)}
|
||||
|
||||
{state.step === "result" && state.result && (
|
||||
<ResultStep result={state.result} onImportMore={handleReset} />
|
||||
)}
|
||||
|
||||
{state.importHistory.length > 0 && (
|
||||
<ImportHistoryList history={state.importHistory} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function ContactHeader({
|
||||
showStartOver,
|
||||
onStartOver,
|
||||
}: {
|
||||
showStartOver: boolean;
|
||||
onStartOver: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded bg-[var(--admin-text-primary)]">
|
||||
{Icons.uploadCloud("w-3 h-3 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
{step !== "idle" && (
|
||||
<button type="button"
|
||||
onClick={handleReset}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
← Start over
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Import Contacts</h3>
|
||||
</div>
|
||||
{showStartOver && (
|
||||
<button type="button"
|
||||
onClick={onStartOver}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
← Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LargeFileBanner() {
|
||||
return (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
|
||||
{Icons.check("h-4 w-4 text-emerald-600")}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-emerald-700">Large file detected</p>
|
||||
<p className="text-xs text-emerald-600">File will be uploaded to cloud storage for processing</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadProgress({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-4 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icons.spinner("h-5 w-5 text-blue-600 animate-spin")}
|
||||
<span className="text-blue-700 font-medium">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactUploadStep({
|
||||
fileRef,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onChange,
|
||||
}: {
|
||||
fileRef: React.RefObject<HTMLInputElement | null>;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="border-2 border-dashed border-[var(--admin-border)] rounded-xl p-8 text-center hover:border-emerald-400 transition-colors"
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input aria-label="File upload"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={onChange}
|
||||
className="hidden"
|
||||
id="csv-upload"
|
||||
/>
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<div className="text-[var(--admin-text-muted)] text-sm">
|
||||
<span className="font-semibold text-emerald-600 hover:text-emerald-700">
|
||||
Click to upload
|
||||
</span>
|
||||
{" "}or drag and drop a CSV file
|
||||
</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
||||
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
|
||||
generic spreadsheets
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--admin-text-muted)] mt-2 text-emerald-600">
|
||||
Large files (5MB+) automatically uploaded to cloud storage
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactPreviewStep({
|
||||
fileName,
|
||||
preview,
|
||||
overridingMappings,
|
||||
setOverridingMappings,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
fileName: string;
|
||||
preview: ImportPreviewResult;
|
||||
overridingMappings: Record<string, ImportField>;
|
||||
setOverridingMappings: Dispatch<SetStateAction<Record<string, ImportField>>>;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<PreviewWarnings warnings={preview.warnings} />
|
||||
<PreviewFileBanner fileName={fileName} />
|
||||
<MappingsView
|
||||
preview={preview}
|
||||
overridingMappings={overridingMappings}
|
||||
setOverridingMappings={setOverridingMappings}
|
||||
/>
|
||||
<PreviewSummary preview={preview} />
|
||||
<PreviewSampleRows preview={preview} />
|
||||
{preview.skippedReasons.length > 0 && <SkippedReasons preview={preview} />}
|
||||
{preview.totalRows > LARGE_FILE_ROWS && <LargeRowWarning preview={preview} />}
|
||||
<PreviewActions
|
||||
validRows={preview.validRows}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewWarnings({ warnings }: { warnings: string[] }) {
|
||||
if (warnings.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{warnings.map((w, i) => (
|
||||
<div
|
||||
key={`${w}-${i}`}
|
||||
className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewFileBanner({ fileName }: { fileName: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
|
||||
<p className="font-semibold mb-1">{fileName}</p>
|
||||
<p>
|
||||
Column mappings detected — review below, adjust if needed, then
|
||||
confirm import.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSummary({ preview }: { preview: ImportPreviewResult }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
|
||||
Import Summary
|
||||
</p>
|
||||
<StatsView preview={preview} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSampleRows({ preview }: { preview: ImportPreviewResult }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
|
||||
Sample rows (first {preview.sampleRows.length})
|
||||
</p>
|
||||
<SampleRowsView preview={preview} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkippedReasons({ preview }: { preview: ImportPreviewResult }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-1 font-semibold">
|
||||
Skipped rows ({preview.skippedReasons.length})
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-[var(--admin-text-muted)] space-y-1">
|
||||
{preview.skippedReasons.slice(0, 10).map((s) => (
|
||||
<p key={s.rowIndex}>
|
||||
Row {s.rowIndex + 1}: {s.reason}
|
||||
</p>
|
||||
))}
|
||||
{preview.skippedReasons.length > 10 && (
|
||||
<p className="text-[var(--admin-text-muted)]">
|
||||
...and{" "}
|
||||
{preview.skippedReasons.length - 10} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Large file banner */}
|
||||
{file && file.size > LARGE_FILE_THRESHOLD && (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
|
||||
{Icons.check("h-4 w-4 text-emerald-600")}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-emerald-700">Large file detected</p>
|
||||
<p className="text-xs text-emerald-600">File will be uploaded to cloud storage for processing</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
function LargeRowWarning({ preview }: { preview: ImportPreviewResult }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
||||
Large file ({preview.totalRows.toLocaleString()} rows) — confirm to
|
||||
proceed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploadProgress && (
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-4 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icons.spinner("h-5 w-5 text-blue-600 animate-spin")}
|
||||
<span className="text-blue-700 font-medium">{uploadProgress}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
function PreviewActions({
|
||||
validRows,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
validRows: number;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={validRows === 0}
|
||||
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Import {validRows} contacts
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{globalError && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
function ImportingStep({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-6 text-center text-sm text-emerald-700 font-semibold">
|
||||
Importing {count} contacts...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* ── STEP: idle ─────────────────────────────────────────── */}
|
||||
{step === "idle" && (
|
||||
<>
|
||||
<div
|
||||
className="border-2 border-dashed border-[var(--admin-border)] rounded-xl p-8 text-center hover:border-emerald-400 transition-colors"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input aria-label="File upload"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="csv-upload"
|
||||
/>
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<div className="text-[var(--admin-text-muted)] text-sm">
|
||||
<span className="font-semibold text-emerald-600 hover:text-emerald-700">
|
||||
Click to upload
|
||||
</span>
|
||||
{" "}or drag and drop a CSV file
|
||||
</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
||||
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
|
||||
generic spreadsheets
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--admin-text-muted)] mt-2 text-emerald-600">
|
||||
Large files (5MB+) automatically uploaded to cloud storage
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
function ResultStep({
|
||||
result,
|
||||
onImportMore,
|
||||
}: {
|
||||
result: ImportResult;
|
||||
onImportMore: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm space-y-1">
|
||||
<p className="font-semibold text-emerald-700 flex items-center gap-2">
|
||||
{Icons.check("h-4 w-4")}
|
||||
Import complete
|
||||
</p>
|
||||
<p className="text-emerald-600">Created: {result.created.toLocaleString()}</p>
|
||||
<p className="text-emerald-600">Updated: {result.updated.toLocaleString()}</p>
|
||||
<p className="text-emerald-600">Skipped: {result.skipped.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
{/* ── STEP: preview ──────────────────────────────────────── */}
|
||||
{step === "preview" && preview && (
|
||||
<>
|
||||
{preview.warnings.map((w, i) => (
|
||||
<div
|
||||
key={`${w}-${i}`}
|
||||
className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
{result.errors.length > 0 && <ResultErrors result={result} />}
|
||||
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
|
||||
<p className="font-semibold mb-1">{file?.name}</p>
|
||||
<p>
|
||||
Column mappings detected — review below, adjust if needed, then
|
||||
confirm import.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onImportMore}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Import more
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="space-y-3">
|
||||
{preview ? (
|
||||
<MappingsView
|
||||
preview={preview}
|
||||
overridingMappings={overridingMappings}
|
||||
setOverridingMappings={setOverridingMappings}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
|
||||
Import Summary
|
||||
</p>
|
||||
{preview ? <StatsView preview={preview} /> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
|
||||
Sample rows (first {preview.sampleRows.length})
|
||||
</p>
|
||||
{preview ? <SampleRowsView preview={preview} /> : null}
|
||||
</div>
|
||||
|
||||
{preview.skippedReasons.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-1 font-semibold">
|
||||
Skipped rows ({preview.skippedReasons.length})
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-[var(--admin-text-muted)] space-y-1">
|
||||
{preview.skippedReasons.slice(0, 10).map((s) => (
|
||||
<p key={s.rowIndex}>
|
||||
Row {s.rowIndex + 1}: {s.reason}
|
||||
</p>
|
||||
))}
|
||||
{preview.skippedReasons.length > 10 && (
|
||||
<p className="text-[var(--admin-text-muted)]">
|
||||
...and{" "}
|
||||
{preview.skippedReasons.length - 10} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.totalRows > LARGE_FILE_ROWS && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
||||
Large file ({preview.totalRows.toLocaleString()} rows) — confirm to
|
||||
proceed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button type="button"
|
||||
onClick={() => handleReset()}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleConfirm()}
|
||||
disabled={preview.validRows === 0}
|
||||
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Import {preview.validRows} contacts
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── STEP: importing ────────────────────────────────────── */}
|
||||
{step === "importing" && (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-6 text-center text-sm text-emerald-700 font-semibold">
|
||||
Importing {importRows.length} contacts...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP: result ────────────────────────────────────────── */}
|
||||
{step === "result" && result && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm space-y-1">
|
||||
<p className="font-semibold text-emerald-700 flex items-center gap-2">
|
||||
{Icons.check("h-4 w-4")}
|
||||
Import complete
|
||||
</p>
|
||||
<p className="text-emerald-600">Created: {result.created.toLocaleString()}</p>
|
||||
<p className="text-emerald-600">Updated: {result.updated.toLocaleString()}</p>
|
||||
<p className="text-emerald-600">Skipped: {result.skipped.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-red-600 mb-2">
|
||||
Errors ({result.errors.length})
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{result.errors.slice(0, 10).map((e, i) => (
|
||||
<p key={`${e.error}-${JSON.stringify(e.row)}-${i}`} className="text-red-600 text-xs">
|
||||
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
|
||||
</p>
|
||||
))}
|
||||
{result.errors.length > 10 && (
|
||||
<p className="text-red-600 text-xs">
|
||||
...and {result.errors.length - 10} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button"
|
||||
onClick={handleReset}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Import more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import history for bucket imports */}
|
||||
{importHistory.length > 0 && (
|
||||
<div className="border-t border-[var(--admin-border)] pt-4 mt-4">
|
||||
<p className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide mb-3">
|
||||
Previous Imports
|
||||
function ResultErrors({ result }: { result: ImportResult }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-red-600 mb-2">
|
||||
Errors ({result.errors.length})
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{result.errors.slice(0, 10).map((e, i) => (
|
||||
<p key={`${e.error}-${JSON.stringify(e.row)}-${i}`} className="text-red-600 text-xs">
|
||||
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{importHistory.slice(0, 5).map((imp, i) => (
|
||||
<div key={`${imp.filename}-${imp.createdAt}-${imp.size}`} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
<span className="text-[var(--admin-text-primary)]">{imp.filename}</span>
|
||||
</div>
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{(imp.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
{result.errors.length > 10 && (
|
||||
<p className="text-red-600 text-xs">
|
||||
...and {result.errors.length - 10} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportHistoryList({ history }: { history: ImportHistoryEntry[] }) {
|
||||
return (
|
||||
<div className="border-t border-[var(--admin-border)] pt-4 mt-4">
|
||||
<p className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide mb-3">
|
||||
Previous Imports
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{history.slice(0, 5).map((imp, i) => (
|
||||
<div key={`${imp.filename}-${imp.createdAt}-${imp.size}-${i}`} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
<span className="text-[var(--admin-text-primary)]">{imp.filename}</span>
|
||||
</div>
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{(imp.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -743,7 +966,7 @@ function SampleRowsView({ preview }: { preview: ImportPreviewResult }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.sampleRows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
|
||||
<tr key={`${row.email ?? ""}-${i}`} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
|
||||
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{i + 1}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.email ?? ""}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.phone ?? ""}</td>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useReducer, useCallback } from "react";
|
||||
import type { Contact, ContactSource } from "@/actions/communications/contacts";
|
||||
import { getContacts, deleteContact, exportContacts } from "@/actions/communications/contacts";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
@@ -112,6 +112,54 @@ function ContactSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
type State = {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
search: string;
|
||||
sourceFilter: ContactSource | "";
|
||||
page: number;
|
||||
loading: boolean;
|
||||
deleting: string | null;
|
||||
exporting: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_PAGE_RESULTS"; contacts: Contact[]; total: number }
|
||||
| { type: "SET_SEARCH"; value: string }
|
||||
| { type: "SET_SOURCE_FILTER"; value: ContactSource | "" }
|
||||
| { type: "SET_PAGE"; value: number }
|
||||
| { type: "SET_LOADING"; value: boolean }
|
||||
| { type: "SET_DELETING"; value: string | null }
|
||||
| { type: "SET_EXPORTING"; value: boolean }
|
||||
| { type: "REMOVE_CONTACT"; id: string };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_PAGE_RESULTS":
|
||||
return { ...state, contacts: action.contacts, total: action.total };
|
||||
case "SET_SEARCH":
|
||||
return { ...state, search: action.value };
|
||||
case "SET_SOURCE_FILTER":
|
||||
return { ...state, sourceFilter: action.value };
|
||||
case "SET_PAGE":
|
||||
return { ...state, page: action.value };
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.value };
|
||||
case "SET_DELETING":
|
||||
return { ...state, deleting: action.value };
|
||||
case "SET_EXPORTING":
|
||||
return { ...state, exporting: action.value };
|
||||
case "REMOVE_CONTACT":
|
||||
return {
|
||||
...state,
|
||||
contacts: state.contacts.filter((c) => c.id !== action.id),
|
||||
total: state.total - 1,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactListPanel({
|
||||
initialContacts,
|
||||
initialTotal,
|
||||
@@ -121,20 +169,22 @@ export default function ContactListPanel({
|
||||
initialTotal: number;
|
||||
brandId: string;
|
||||
}) {
|
||||
const [contacts, setContacts] = useState(initialContacts);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sourceFilter, setSourceFilter] = useState<ContactSource | "">("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const limit = 50;
|
||||
const [state, dispatch] = useReducer(reducer, undefined, () => ({
|
||||
contacts: initialContacts,
|
||||
total: initialTotal,
|
||||
search: "",
|
||||
sourceFilter: "" as ContactSource | "",
|
||||
page: 0,
|
||||
loading: false,
|
||||
deleting: null,
|
||||
exporting: false,
|
||||
}));
|
||||
|
||||
const hasFilters = search.length > 0 || sourceFilter !== "";
|
||||
const hasFilters = state.search.length > 0 || state.sourceFilter !== "";
|
||||
|
||||
const loadPage = useCallback(async (searchVal: string, sourceVal: string, pageNum: number) => {
|
||||
setLoading(true);
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
const result = await getContacts({
|
||||
brandId,
|
||||
search: searchVal || undefined,
|
||||
@@ -142,51 +192,49 @@ export default function ContactListPanel({
|
||||
limit,
|
||||
offset: pageNum * limit,
|
||||
});
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
if (result.success) {
|
||||
setContacts(result.contacts);
|
||||
setTotal(result.total);
|
||||
dispatch({ type: "SET_PAGE_RESULTS", contacts: result.contacts, total: result.total });
|
||||
}
|
||||
}, [brandId]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPage(0);
|
||||
loadPage(search, sourceFilter, 0);
|
||||
};
|
||||
dispatch({ type: "SET_PAGE", value: 0 });
|
||||
loadPage(state.search, state.sourceFilter, 0);
|
||||
}
|
||||
|
||||
const handleSourceFilter = (val: ContactSource | "") => {
|
||||
setSourceFilter(val);
|
||||
setPage(0);
|
||||
loadPage(search, val, 0);
|
||||
};
|
||||
function handleSourceFilter(val: ContactSource | "") {
|
||||
dispatch({ type: "SET_SOURCE_FILTER", value: val });
|
||||
dispatch({ type: "SET_PAGE", value: 0 });
|
||||
loadPage(state.search, val, 0);
|
||||
}
|
||||
|
||||
const handlePage = (dir: number) => {
|
||||
const next = page + dir;
|
||||
setPage(next);
|
||||
loadPage(search, sourceFilter, next);
|
||||
};
|
||||
function handlePage(dir: number) {
|
||||
const next = state.page + dir;
|
||||
dispatch({ type: "SET_PAGE", value: next });
|
||||
loadPage(state.search, state.sourceFilter, next);
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("Delete this contact? This action cannot be undone.")) return;
|
||||
setDeleting(id);
|
||||
dispatch({ type: "SET_DELETING", value: id });
|
||||
const result = await deleteContact(id);
|
||||
setDeleting(null);
|
||||
dispatch({ type: "SET_DELETING", value: null });
|
||||
if (result.success) {
|
||||
setContacts((prev) => prev.filter((c) => c.id !== id));
|
||||
setTotal((t) => t - 1);
|
||||
dispatch({ type: "REMOVE_CONTACT", id });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
async function handleExport() {
|
||||
dispatch({ type: "SET_EXPORTING", value: true });
|
||||
const result = await exportContacts({
|
||||
brandId,
|
||||
brandSlug: "contacts",
|
||||
search: search || undefined,
|
||||
source: sourceFilter || undefined,
|
||||
search: state.search || undefined,
|
||||
source: state.sourceFilter || undefined,
|
||||
});
|
||||
setExporting(false);
|
||||
dispatch({ type: "SET_EXPORTING", value: false });
|
||||
if (!result.success) {
|
||||
alert("Export failed: " + result.error);
|
||||
return;
|
||||
@@ -200,217 +248,339 @@ export default function ContactListPanel({
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
{Icons.users("w-6 h-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-stone-900">Contacts</h2>
|
||||
<p className="text-sm text-stone-500">{total.toLocaleString()} contact{total !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminButton variant="secondary" onClick={handleExport} disabled={exporting || total === 0} icon={Icons.download("w-4 h-4")}>
|
||||
{exporting ? "Exporting..." : "Export"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
<ContactsHeader
|
||||
total={state.total}
|
||||
exporting={state.exporting}
|
||||
canExport={state.total > 0}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
|
||||
{/* Search + filters */}
|
||||
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
|
||||
{Icons.search("h-5 w-5 text-stone-400")}
|
||||
</div>
|
||||
<input aria-label="Search Email, Name, Phone..."
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search email, name, phone..."
|
||||
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select aria-label="Select"
|
||||
value={sourceFilter}
|
||||
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
|
||||
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="order">From Orders</option>
|
||||
<option value="import">Imported</option>
|
||||
<option value="manual">Manual Entry</option>
|
||||
<option value="admin">Admin Added</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 active:bg-emerald-800 transition-colors shadow-sm"
|
||||
aria-label="Search">
|
||||
Search
|
||||
</button>
|
||||
<form onSubmit={handleSearch}>
|
||||
<SearchAndFilters
|
||||
search={state.search}
|
||||
sourceFilter={state.sourceFilter}
|
||||
dispatch={dispatch}
|
||||
onSourceFilter={handleSourceFilter}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
{state.loading ? (
|
||||
<ContactSkeleton />
|
||||
) : contacts.length === 0 ? (
|
||||
) : state.contacts.length === 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<EmptyState hasFilters={hasFilters} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Name</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Phone</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Source</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email Opt</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Unsubscribed</th>
|
||||
<th className="text-right px-4 py-3.5" aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
|
||||
{contacts.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 font-semibold text-stone-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
|
||||
{(c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "??").slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span>{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-600">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Icons.mail("w-4 h-4 text-stone-400")}
|
||||
<span className="truncate max-w-[150px]">{c.email || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-600">{c.phone || "—"}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{c.email_opt_in ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Opted in
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
Opted out
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs">
|
||||
{c.unsubscribed_at ? formatDate(new Date(c.unsubscribed_at)) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<button type="button"
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deleting === c.id}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-700 disabled:opacity-50 transition-colors"
|
||||
title="Delete contact"
|
||||
aria-label="Delete contact">
|
||||
{Icons.trash("h-4 w-4")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ContactsList
|
||||
contacts={state.contacts}
|
||||
deleting={state.deleting}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{contacts.map((c) => (
|
||||
<div key={c.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
|
||||
{(c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "??").slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-stone-800 text-sm">
|
||||
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">{c.email || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deleting === c.id}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
{Icons.trash("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
{c.phone && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-stone-500">
|
||||
{c.phone}
|
||||
</span>
|
||||
)}
|
||||
{c.email_opt_in ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
|
||||
Opted in
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
|
||||
Opted out
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
|
||||
<span className="text-sm text-stone-500">
|
||||
Showing {page * limit + 1}–{Math.min((page + 1) * limit, total)} of {total.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
onClick={() => handlePage(-1)}
|
||||
disabled={page === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
|
||||
aria-label="Previous">
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
<span>Previous</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handlePage(1)}
|
||||
disabled={(page + 1) * limit >= total}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
|
||||
aria-label="Next">
|
||||
<span>Next</span>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
page={state.page}
|
||||
total={state.total}
|
||||
limit={limit}
|
||||
onPage={handlePage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactsHeader({
|
||||
total,
|
||||
exporting,
|
||||
canExport,
|
||||
onExport,
|
||||
}: {
|
||||
total: number;
|
||||
exporting: boolean;
|
||||
canExport: boolean;
|
||||
onExport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
{Icons.users("w-6 h-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-stone-900">Contacts</h2>
|
||||
<p className="text-sm text-stone-500">{total.toLocaleString()} contact{total !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminButton variant="secondary" onClick={onExport} disabled={exporting || !canExport} icon={Icons.download("w-4 h-4")}>
|
||||
{exporting ? "Exporting..." : "Export"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchAndFilters({
|
||||
search,
|
||||
sourceFilter,
|
||||
dispatch,
|
||||
onSourceFilter,
|
||||
}: {
|
||||
search: string;
|
||||
sourceFilter: ContactSource | "";
|
||||
dispatch: React.Dispatch<Action>;
|
||||
onSourceFilter: (val: ContactSource | "") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
|
||||
{Icons.search("h-5 w-5 text-stone-400")}
|
||||
</div>
|
||||
<input aria-label="Search Email, Name, Phone..."
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => dispatch({ type: "SET_SEARCH", value: e.target.value })}
|
||||
placeholder="Search email, name, phone..."
|
||||
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select aria-label="Select"
|
||||
value={sourceFilter}
|
||||
onChange={(e) => onSourceFilter(e.target.value as ContactSource | "")}
|
||||
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="order">From Orders</option>
|
||||
<option value="import">Imported</option>
|
||||
<option value="manual">Manual Entry</option>
|
||||
<option value="admin">Admin Added</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 active:bg-emerald-800 transition-colors shadow-sm"
|
||||
aria-label="Search">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactsList({
|
||||
contacts,
|
||||
deleting,
|
||||
onDelete,
|
||||
}: {
|
||||
contacts: Contact[];
|
||||
deleting: string | null;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Name</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Phone</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Source</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email Opt</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Unsubscribed</th>
|
||||
<th className="text-right px-4 py-3.5" aria-label="Actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
|
||||
{contacts.map((c) => (
|
||||
<ContactTableRow
|
||||
key={c.id}
|
||||
contact={c}
|
||||
deleting={deleting === c.id}
|
||||
onDelete={() => onDelete(c.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{contacts.map((c) => (
|
||||
<ContactMobileCard
|
||||
key={c.id}
|
||||
contact={c}
|
||||
deleting={deleting === c.id}
|
||||
onDelete={() => onDelete(c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactTableRow({
|
||||
contact: c,
|
||||
deleting,
|
||||
onDelete,
|
||||
}: {
|
||||
contact: Contact;
|
||||
deleting: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const displayName = c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—";
|
||||
return (
|
||||
<tr className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 font-semibold text-stone-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-600">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Icons.mail("w-4 h-4 text-stone-400")}
|
||||
<span className="truncate max-w-[150px]">{c.email || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-600">{c.phone || "—"}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{c.email_opt_in ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Opted in
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
Opted out
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs">
|
||||
{c.unsubscribed_at ? formatDate(new Date(c.unsubscribed_at)) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<button type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deleting}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-700 disabled:opacity-50 transition-colors"
|
||||
title="Delete contact"
|
||||
aria-label="Delete contact">
|
||||
{Icons.trash("h-4 w-4")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactMobileCard({
|
||||
contact: c,
|
||||
deleting,
|
||||
onDelete,
|
||||
}: {
|
||||
contact: Contact;
|
||||
deleting: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const displayName = c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—";
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-stone-800 text-sm">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">{c.email || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deleting}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
{Icons.trash("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
{c.phone && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-stone-500">
|
||||
{c.phone}
|
||||
</span>
|
||||
)}
|
||||
{c.email_opt_in ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
|
||||
Opted in
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
|
||||
Opted out
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pagination({
|
||||
page,
|
||||
total,
|
||||
limit,
|
||||
onPage,
|
||||
}: {
|
||||
page: number;
|
||||
total: number;
|
||||
limit: number;
|
||||
onPage: (dir: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
|
||||
<span className="text-sm text-stone-500">
|
||||
Showing {page * limit + 1}–{Math.min((page + 1) * limit, total)} of {total.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
onClick={() => onPage(-1)}
|
||||
disabled={page === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
|
||||
aria-label="Previous">
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
<span>Previous</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => onPage(1)}
|
||||
disabled={(page + 1) * limit >= total}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
|
||||
aria-label="Next">
|
||||
<span>Next</span>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer, useEffect } from "react";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
import { AdminInput, AdminTextInput } from "@/components/admin/design-system";
|
||||
|
||||
type Role = "platform_admin" | "brand_admin" | "store_employee";
|
||||
|
||||
@@ -12,10 +11,12 @@ const roleDescriptions: Record<Role, string> = {
|
||||
store_employee: "Pickup and order operations only",
|
||||
};
|
||||
|
||||
type AdminUserRow = import("@/actions/admin/users").AdminUserRow;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (user: import("@/actions/admin/users").AdminUserRow) => void;
|
||||
onSuccess: (user: AdminUserRow) => void;
|
||||
brands: { id: string; name: string }[];
|
||||
currentUser: {
|
||||
role: string;
|
||||
@@ -23,7 +24,7 @@ type Props = {
|
||||
};
|
||||
|
||||
type SuccessState = {
|
||||
user: import("@/actions/admin/users").AdminUserRow;
|
||||
user: AdminUserRow;
|
||||
tempPassword: string;
|
||||
emailSent: boolean;
|
||||
emailError?: string;
|
||||
@@ -56,6 +57,80 @@ const defaultFlags: Record<string, boolean> = {
|
||||
can_manage_reports: false,
|
||||
};
|
||||
|
||||
type State = {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
phoneNumber: string;
|
||||
role: Role;
|
||||
brandId: string | null;
|
||||
flags: Record<string, boolean>;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
success: SuccessState | null;
|
||||
copied: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_EMAIL"; value: string }
|
||||
| { type: "SET_PASSWORD"; value: string }
|
||||
| { type: "SET_DISPLAY_NAME"; value: string }
|
||||
| { type: "SET_PHONE_NUMBER"; value: string }
|
||||
| { type: "SET_ROLE"; value: Role }
|
||||
| { type: "SET_BRAND"; value: string | null }
|
||||
| { type: "TOGGLE_FLAG"; flag: string }
|
||||
| { type: "SET_SAVING"; value: boolean }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SET_SUCCESS"; value: SuccessState | null }
|
||||
| { type: "SET_COPIED"; value: boolean }
|
||||
| { type: "RESET" };
|
||||
|
||||
const initialState: State = {
|
||||
email: "",
|
||||
password: "",
|
||||
displayName: "",
|
||||
phoneNumber: "",
|
||||
role: "store_employee",
|
||||
brandId: null,
|
||||
flags: { ...defaultFlags },
|
||||
saving: false,
|
||||
error: null,
|
||||
success: null,
|
||||
copied: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_EMAIL":
|
||||
return { ...state, email: action.value };
|
||||
case "SET_PASSWORD":
|
||||
return { ...state, password: action.value };
|
||||
case "SET_DISPLAY_NAME":
|
||||
return { ...state, displayName: action.value };
|
||||
case "SET_PHONE_NUMBER":
|
||||
return { ...state, phoneNumber: action.value };
|
||||
case "SET_ROLE":
|
||||
return { ...state, role: action.value };
|
||||
case "SET_BRAND":
|
||||
return { ...state, brandId: action.value };
|
||||
case "TOGGLE_FLAG":
|
||||
return { ...state, flags: { ...state.flags, [action.flag]: !state.flags[action.flag] } };
|
||||
case "SET_SAVING":
|
||||
return { ...state, saving: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SET_SUCCESS":
|
||||
return { ...state, success: action.value };
|
||||
case "SET_COPIED":
|
||||
return { ...state, copied: action.value };
|
||||
case "RESET":
|
||||
return {
|
||||
...initialState,
|
||||
flags: { ...defaultFlags },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const UserIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
@@ -66,215 +141,102 @@ const UserIcon = ({ className }: { className?: string }) => (
|
||||
);
|
||||
|
||||
export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, currentUser }: Props) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [role, setRole] = useState<Role>("store_employee");
|
||||
const [brandId, setBrandId] = useState<string | null>(null);
|
||||
const [flags, setFlags] = useState<Record<string, boolean>>({ ...defaultFlags });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<SuccessState | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const showBrandSelect = role === "brand_admin" || role === "store_employee";
|
||||
const showBrandSelect = state.role === "brand_admin" || state.role === "store_employee";
|
||||
|
||||
const availableRoles: Role[] =
|
||||
currentUser.role === "platform_admin"
|
||||
? ["platform_admin", "brand_admin", "store_employee"]
|
||||
: ["brand_admin", "store_employee"];
|
||||
|
||||
function toggleFlag(flag: string) {
|
||||
setFlags((prev) => ({ ...prev, [flag]: !prev[flag] }));
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setDisplayName("");
|
||||
setPhoneNumber("");
|
||||
setRole("store_employee");
|
||||
setBrandId(null);
|
||||
setFlags({ ...defaultFlags });
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setCopied(false);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email.includes("@") || !password || password.length < 6) {
|
||||
setError("Please enter a valid email and a password with at least 6 characters.");
|
||||
if (!state.email.includes("@") || !state.password || state.password.length < 6) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
value: "Please enter a valid email and a password with at least 6 characters.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
dispatch({ type: "SET_SAVING", value: true });
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
|
||||
try {
|
||||
const { createAdminUser } = await import("@/actions/admin/users");
|
||||
const result = await createAdminUser({
|
||||
email,
|
||||
password,
|
||||
display_name: displayName || undefined,
|
||||
phone_number: phoneNumber || undefined,
|
||||
role,
|
||||
brand_id: brandId,
|
||||
flags,
|
||||
email: state.email,
|
||||
password: state.password,
|
||||
display_name: state.displayName || undefined,
|
||||
phone_number: state.phoneNumber || undefined,
|
||||
role: state.role,
|
||||
brand_id: state.brandId,
|
||||
flags: state.flags,
|
||||
mustChangePassword: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
dispatch({ type: "SET_ERROR", value: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.user && result.tempPassword) {
|
||||
onSuccess(result.user);
|
||||
setSuccess({
|
||||
user: result.user,
|
||||
tempPassword: result.tempPassword,
|
||||
emailSent: result.emailSent ?? false,
|
||||
emailError: result.emailError,
|
||||
authPath: result.authPath,
|
||||
dispatch({
|
||||
type: "SET_SUCCESS",
|
||||
value: {
|
||||
user: result.user,
|
||||
tempPassword: result.tempPassword,
|
||||
emailSent: result.emailSent ?? false,
|
||||
emailError: result.emailError,
|
||||
authPath: result.authPath,
|
||||
},
|
||||
});
|
||||
// Don't close — show the success state so the caller can copy the temp password.
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "An unexpected error occurred.");
|
||||
dispatch({ type: "SET_ERROR", value: e instanceof Error ? e.message : "An unexpected error occurred." });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
dispatch({ type: "SET_SAVING", value: false });
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (!saving) {
|
||||
resetForm();
|
||||
if (!state.saving) {
|
||||
dispatch({ type: "RESET" });
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
if (!success) return;
|
||||
if (!state.success) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(success.tempPassword);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
await navigator.clipboard.writeText(state.success.tempPassword);
|
||||
dispatch({ type: "SET_COPIED", value: true });
|
||||
window.setTimeout(() => dispatch({ type: "SET_COPIED", value: false }), 2000);
|
||||
} catch {
|
||||
// Clipboard not available — caller can select manually.
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-reset "copied" timer cleanup: if the modal closes while the timeout
|
||||
// is pending, the dispatch would otherwise land on an unmounted component.
|
||||
useEffect(() => {
|
||||
if (!state.copied) return;
|
||||
const id = window.setTimeout(() => dispatch({ type: "SET_COPIED", value: false }), 2000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [state.copied]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Success state — show temp password + email status, then "Done" closes.
|
||||
if (success) {
|
||||
if (state.success) {
|
||||
return (
|
||||
<GlassModal
|
||||
title="User Created"
|
||||
titleIcon={
|
||||
<svg className="h-5 w-5 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
}
|
||||
subtitle={`${success.user.display_name ?? success.user.email} is ready to sign in`}
|
||||
<SuccessView
|
||||
success={state.success}
|
||||
copied={state.copied}
|
||||
onCopy={copyPassword}
|
||||
onClose={handleClose}
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
The account has been created in Neon Auth and linked to the local admin record.
|
||||
Share the temporary password below with the new user — it will not be shown again.
|
||||
</p>
|
||||
|
||||
{/* Email */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-3 sm:p-4">
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
|
||||
Sign-in email
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)] break-all">
|
||||
{success.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temp password */}
|
||||
<div className="rounded-xl border border-amber-300/80 bg-amber-50/80 p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-800">
|
||||
Temporary password
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPassword}
|
||||
className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-900 hover:text-amber-700 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-mono text-sm text-amber-900 break-all select-all">
|
||||
{success.tempPassword}
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-700 mt-2">
|
||||
The user should change this on first sign-in (must_change_password is set).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email delivery status */}
|
||||
<div
|
||||
className={`rounded-xl border p-3 sm:p-4 ${
|
||||
success.emailSent
|
||||
? "border-emerald-200/80 bg-emerald-50/60"
|
||||
: "border-rose-200/80 bg-rose-50/60"
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
|
||||
Welcome email
|
||||
</p>
|
||||
{success.emailSent ? (
|
||||
<p className="text-sm text-emerald-800">
|
||||
Sent to {success.user.email}. The user has the password in their inbox.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-rose-800">
|
||||
Could not be sent automatically
|
||||
{success.emailError ? `: ${success.emailError}` : "."} Share the password above out-of-band.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success.authPath === "signup" && (
|
||||
<p className="text-[11px] text-[var(--admin-text-muted)]">
|
||||
Note: the admin sign-up endpoint was unavailable, so the account was created via
|
||||
the public sign-up path. This is fine — the user can sign in normally.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
|
||||
<button type="button"
|
||||
onClick={handleClose}
|
||||
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||
aria-label="Done">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</GlassModal>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,11 +249,10 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
{state.error && (
|
||||
<div className="flex items-start justify-between rounded-lg bg-red-50 p-3 sm:p-4 text-sm text-red-700 gap-3 border border-red-200">
|
||||
<span className="text-xs sm:text-sm">{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0" aria-label="Close">
|
||||
<span className="text-xs sm:text-sm">{state.error}</span>
|
||||
<button type="button" onClick={() => dispatch({ type: "SET_ERROR", value: null })} className="text-red-400 hover:text-red-600 shrink-0" aria-label="Close">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -299,167 +260,381 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="create-user-email" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input aria-label="User@example.com"
|
||||
id="create-user-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
autoComplete="email"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<EmailField
|
||||
value={state.email}
|
||||
onChange={(v) => dispatch({ type: "SET_EMAIL", value: v })}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="create-user-password" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<p id="create-user-password-help" className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p>
|
||||
<input aria-label="Choose A Password"
|
||||
id="create-user-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="create-user-password-help"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
placeholder="Choose a password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<PasswordField
|
||||
value={state.password}
|
||||
onChange={(v) => dispatch({ type: "SET_PASSWORD", value: v })}
|
||||
/>
|
||||
|
||||
{/* Display Name & Phone - 2 columns on larger screens */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div>
|
||||
<label htmlFor="create-user-display-name" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Display Name
|
||||
</label>
|
||||
<input aria-label="Kyle Martinez"
|
||||
id="create-user-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="name"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
placeholder="Kyle Martinez"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="create-user-phone" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Phone Number
|
||||
</label>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Optional.</p>
|
||||
<input aria-label="+1 (555) 000 0000"
|
||||
id="create-user-phone"
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
autoComplete="tel"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
/>
|
||||
</div>
|
||||
<DisplayNameField
|
||||
value={state.displayName}
|
||||
onChange={(v) => dispatch({ type: "SET_DISPLAY_NAME", value: v })}
|
||||
/>
|
||||
<PhoneNumberField
|
||||
value={state.phoneNumber}
|
||||
onChange={(v) => dispatch({ type: "SET_PHONE_NUMBER", value: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<p className="block text-sm font-medium text-[var(--admin-text-primary)] mb-2">Role</p>
|
||||
<div className="space-y-2">
|
||||
{availableRoles.map((r) => (
|
||||
<label key={r} className="flex items-center gap-3 rounded-lg border border-[var(--admin-border)] px-3 py-2.5 cursor-pointer hover:bg-[var(--admin-bg)] transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value={r}
|
||||
checked={role === r}
|
||||
onChange={() => setRole(r)}
|
||||
className="accent-[var(--admin-accent)]"
|
||||
aria-label="Role"/>
|
||||
<div>
|
||||
<span className="font-medium text-[var(--admin-text-primary)] capitalize text-sm">{r.replace("_", " ")}</span>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{roleDescriptions[r]}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<RoleSelector
|
||||
availableRoles={availableRoles}
|
||||
value={state.role}
|
||||
onChange={(r) => dispatch({ type: "SET_ROLE", value: r })}
|
||||
/>
|
||||
|
||||
{/* Brand */}
|
||||
{showBrandSelect && (
|
||||
<div>
|
||||
<label htmlFor="create-user-brand" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Brand</label>
|
||||
<select aria-label="Create User Brand"
|
||||
id="create-user-brand"
|
||||
value={brandId ?? ""}
|
||||
onChange={(e) => setBrandId(e.target.value || null)}
|
||||
required
|
||||
aria-required="true"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="">Select a brand</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<BrandSelect
|
||||
brands={brands}
|
||||
value={state.brandId}
|
||||
onChange={(v) => dispatch({ type: "SET_BRAND", value: v })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
<div>
|
||||
<p className="block text-sm font-medium text-[var(--admin-text-primary)] mb-2">Permissions</p>
|
||||
<div className="space-y-2">
|
||||
{ALL_FLAGS.map((flag) => (
|
||||
<label key={flag} className="flex items-center justify-between rounded-lg border border-[var(--admin-border)] px-3 sm:px-4 py-2 sm:py-2.5 hover:bg-[var(--admin-bg)] cursor-pointer transition-colors">
|
||||
<span className="text-xs sm:text-sm text-[var(--admin-text-primary)]">{FLAG_LABELS[flag]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFlag(flag)}
|
||||
className={`relative inline-flex h-5 w-9 sm:h-6 sm:w-11 items-center rounded-full transition-colors ${flags[flag] ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full bg-white shadow transition-transform ${flags[flag] ? "translate-x-5 sm:translate-x-6" : "translate-x-0.5 sm:translate-x-1"}`} />
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PermissionsList
|
||||
flags={state.flags}
|
||||
onToggle={(flag) => dispatch({ type: "TOGGLE_FLAG", flag })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col-reverse sm:flex-row items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
|
||||
<button type="button"
|
||||
onClick={handleClose}
|
||||
disabled={saving}
|
||||
className="w-full sm:w-auto rounded-xl border border-[var(--admin-border)] px-4 sm:px-5 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors disabled:opacity-50"
|
||||
aria-label="Cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || !email.includes("@") || !password || password.length < 6}
|
||||
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors disabled:opacity-50"
|
||||
<FooterActions
|
||||
saving={state.saving}
|
||||
canSubmit={!!state.email.includes("@") && !!state.password && state.password.length >= 6}
|
||||
onCancel={handleClose}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
type SuccessViewProps = {
|
||||
success: SuccessState;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function SuccessView({ success, copied, onCopy, onClose }: SuccessViewProps) {
|
||||
return (
|
||||
<GlassModal
|
||||
title="User Created"
|
||||
titleIcon={
|
||||
<svg className="h-5 w-5 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
}
|
||||
subtitle={`${success.user.display_name ?? success.user.email} is ready to sign in`}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
The account has been created in Neon Auth and linked to the local admin record.
|
||||
Share the temporary password below with the new user — it will not be shown again.
|
||||
</p>
|
||||
|
||||
{/* Email */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-3 sm:p-4">
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
|
||||
Sign-in email
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)] break-all">
|
||||
{success.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temp password */}
|
||||
<div className="rounded-xl border border-amber-300/80 bg-amber-50/80 p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-800">
|
||||
Temporary password
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-900 hover:text-amber-700 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-mono text-sm text-amber-900 break-all select-all">
|
||||
{success.tempPassword}
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-700 mt-2">
|
||||
The user should change this on first sign-in (must_change_password is set).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email delivery status */}
|
||||
<div
|
||||
className={`rounded-xl border p-3 sm:p-4 ${
|
||||
success.emailSent
|
||||
? "border-emerald-200/80 bg-emerald-50/60"
|
||||
: "border-rose-200/80 bg-rose-50/60"
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Creating...
|
||||
</span>
|
||||
) : "Create User"}
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
|
||||
Welcome email
|
||||
</p>
|
||||
{success.emailSent ? (
|
||||
<p className="text-sm text-emerald-800">
|
||||
Sent to {success.user.email}. The user has the password in their inbox.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-rose-800">
|
||||
Could not be sent automatically
|
||||
{success.emailError ? `: ${success.emailError}` : "."} Share the password above out-of-band.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success.authPath === "signup" && (
|
||||
<p className="text-[11px] text-[var(--admin-text-muted)]">
|
||||
Note: the admin sign-up endpoint was unavailable, so the account was created via
|
||||
the public sign-up path. This is fine — the user can sign in normally.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||
aria-label="Done">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
function EmailField({ value, onChange }: FieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="create-user-email" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input aria-label="User@example.com"
|
||||
id="create-user-email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
autoComplete="email"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordField({ value, onChange }: FieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="create-user-password" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<p id="create-user-password-help" className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p>
|
||||
<input aria-label="Choose A Password"
|
||||
id="create-user-password"
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="create-user-password-help"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
placeholder="Choose a password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayNameField({ value, onChange }: FieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="create-user-display-name" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Display Name
|
||||
</label>
|
||||
<input aria-label="Kyle Martinez"
|
||||
id="create-user-display-name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="name"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
placeholder="Kyle Martinez"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneNumberField({ value, onChange }: FieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="create-user-phone" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Phone Number
|
||||
</label>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Optional.</p>
|
||||
<input aria-label="+1 (555) 000 0000"
|
||||
id="create-user-phone"
|
||||
type="tel"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="tel"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RoleSelectorProps = {
|
||||
availableRoles: Role[];
|
||||
value: Role;
|
||||
onChange: (value: Role) => void;
|
||||
};
|
||||
|
||||
function RoleSelector({ availableRoles, value, onChange }: RoleSelectorProps) {
|
||||
return (
|
||||
<div>
|
||||
<p className="block text-sm font-medium text-[var(--admin-text-primary)] mb-2">Role</p>
|
||||
<div className="space-y-2">
|
||||
{availableRoles.map((r) => (
|
||||
<label key={r} className="flex items-center gap-3 rounded-lg border border-[var(--admin-border)] px-3 py-2.5 cursor-pointer hover:bg-[var(--admin-bg)] transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value={r}
|
||||
checked={value === r}
|
||||
onChange={() => onChange(r)}
|
||||
className="accent-[var(--admin-accent)]"
|
||||
aria-label="Role"/>
|
||||
<div>
|
||||
<span className="font-medium text-[var(--admin-text-primary)] capitalize text-sm">{r.replace("_", " ")}</span>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{roleDescriptions[r]}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BrandSelectProps = {
|
||||
brands: { id: string; name: string }[];
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
};
|
||||
|
||||
function BrandSelect({ brands, value, onChange }: BrandSelectProps) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="create-user-brand" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Brand</label>
|
||||
<select aria-label="Create User Brand"
|
||||
id="create-user-brand"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
required
|
||||
aria-required="true"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="">Select a brand</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PermissionsListProps = {
|
||||
flags: Record<string, boolean>;
|
||||
onToggle: (flag: string) => void;
|
||||
};
|
||||
|
||||
function PermissionsList({ flags, onToggle }: PermissionsListProps) {
|
||||
return (
|
||||
<div>
|
||||
<p className="block text-sm font-medium text-[var(--admin-text-primary)] mb-2">Permissions</p>
|
||||
<div className="space-y-2">
|
||||
{ALL_FLAGS.map((flag) => (
|
||||
<label key={flag} className="flex items-center justify-between rounded-lg border border-[var(--admin-border)] px-3 sm:px-4 py-2 sm:py-2.5 hover:bg-[var(--admin-bg)] cursor-pointer transition-colors">
|
||||
<span className="text-xs sm:text-sm text-[var(--admin-text-primary)]">{FLAG_LABELS[flag]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(flag)}
|
||||
className={`relative inline-flex h-5 w-9 sm:h-6 sm:w-11 items-center rounded-full transition-colors ${flags[flag] ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full bg-white shadow transition-transform ${flags[flag] ? "translate-x-5 sm:translate-x-6" : "translate-x-0.5 sm:translate-x-1"}`} />
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FooterActionsProps = {
|
||||
saving: boolean;
|
||||
canSubmit: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function FooterActions({ saving, canSubmit, onCancel, onSubmit }: FooterActionsProps) {
|
||||
return (
|
||||
<div className="flex flex-col-reverse sm:flex-row items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="w-full sm:w-auto rounded-xl border border-[var(--admin-border)] px-4 sm:px-5 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors disabled:opacity-50"
|
||||
aria-label="Cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={saving || !canSubmit}
|
||||
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Creating...
|
||||
</span>
|
||||
) : "Create User"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useReducer } from "react";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge, KPIStat, EmptyState } from "@/components/admin/design-system";
|
||||
import { ShoppingCart, MapPin, Package, DollarSign, Plus, Send, AlertCircle, Calendar, Inbox, Sparkles, BarChart3, BrainCircuit, Upload, Clock, Droplets, Route, Truck, Receipt, Settings as SettingsIcon, Store, type LucideIcon } from "lucide-react";
|
||||
import { ShoppingCart, MapPin, Package, DollarSign, Plus, Send, AlertCircle, Inbox, Sparkles, BarChart3, BrainCircuit, Upload, Clock, Droplets, Route, Truck, Receipt, Settings as SettingsIcon, Store, type LucideIcon } from "lucide-react";
|
||||
import type { DashboardStats } from "@/actions/dashboard";
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
@@ -74,6 +74,25 @@ const quickActions = [
|
||||
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
|
||||
];
|
||||
|
||||
type State = {
|
||||
isUpgradeOpen: boolean;
|
||||
groupFilter: GroupFilter;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "OPEN_UPGRADE" }
|
||||
| { type: "CLOSE_UPGRADE" }
|
||||
| { type: "SET_GROUP_FILTER"; value: GroupFilter };
|
||||
|
||||
type AttentionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
href: string;
|
||||
tone: "primary" | "accent" | "warning" | "danger";
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
brandId: string | null;
|
||||
brandName: string;
|
||||
@@ -85,14 +104,16 @@ type Props = {
|
||||
stats: DashboardStats;
|
||||
};
|
||||
|
||||
type AttentionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
href: string;
|
||||
tone: "primary" | "accent" | "warning" | "danger";
|
||||
icon: LucideIcon;
|
||||
};
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "OPEN_UPGRADE":
|
||||
return { ...state, isUpgradeOpen: true };
|
||||
case "CLOSE_UPGRADE":
|
||||
return { ...state, isUpgradeOpen: false };
|
||||
case "SET_GROUP_FILTER":
|
||||
return { ...state, groupFilter: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashboardClient({
|
||||
brandId,
|
||||
@@ -104,8 +125,10 @@ export default function DashboardClient({
|
||||
limits,
|
||||
stats,
|
||||
}: Props) {
|
||||
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
|
||||
const [groupFilter, setGroupFilter] = useState<GroupFilter>("all");
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
isUpgradeOpen: false,
|
||||
groupFilter: "all",
|
||||
});
|
||||
|
||||
const usagePct = {
|
||||
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
||||
@@ -169,318 +192,46 @@ export default function DashboardClient({
|
||||
for (const s of sections) {
|
||||
if (s.title === "Water Log" && !isWaterLogVisible) continue;
|
||||
if (s.title === "Route Trace" && !enabledAddons["route_trace"]) continue;
|
||||
if (groupFilter !== "all" && s.group !== groupFilter) continue;
|
||||
if (state.groupFilter !== "all" && s.group !== state.groupFilter) continue;
|
||||
visibleSections.push(s);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
{/* Page Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6">
|
||||
<PageHeader
|
||||
icon={<Inbox className="h-5 w-5" />}
|
||||
title="Dashboard"
|
||||
subtitle={brandName}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/settings/billing" className="hidden sm:block text-xs font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Billing →
|
||||
</Link>
|
||||
{planTier === "starter" && brandId && (
|
||||
<AdminButton onClick={() => setIsUpgradeOpen(true)} size="sm">
|
||||
Upgrade Plan
|
||||
</AdminButton>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="mb-0"
|
||||
<DashboardHeader
|
||||
brandName={brandName}
|
||||
planTier={planTier}
|
||||
brandId={brandId}
|
||||
onOpenUpgrade={() => dispatch({ type: "OPEN_UPGRADE" })}
|
||||
/>
|
||||
|
||||
<div className="px-4 sm:px-6 md:px-8 pb-8 space-y-5">
|
||||
<KPIStrip stats={stats} usage={usage} />
|
||||
|
||||
<CommandCenter
|
||||
attentionItems={attentionItems}
|
||||
planTier={planTier}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
usage={usage}
|
||||
limits={limits}
|
||||
usagePct={usagePct}
|
||||
/>
|
||||
|
||||
<RecentOrdersCard orders={stats.recentOrders ?? []} />
|
||||
|
||||
<SectionsGrid
|
||||
groupFilter={state.groupFilter}
|
||||
visibleSections={visibleSections}
|
||||
enabledAddons={enabledAddons}
|
||||
onChangeFilter={(value) => dispatch({ type: "SET_GROUP_FILTER", value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pb-8 space-y-5">
|
||||
|
||||
{/* ── KPI Strip (top metrics) ──────────────────────────── */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KPIStat
|
||||
label="Today's Orders"
|
||||
value={stats.todayOrders}
|
||||
icon={<ShoppingCart className="w-4 h-4" />}
|
||||
tone="primary"
|
||||
/>
|
||||
<KPIStat
|
||||
label="Today's Revenue"
|
||||
value={formatCurrency(stats.todayRevenue)}
|
||||
icon={<DollarSign className="w-4 h-4" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<KPIStat
|
||||
label="Pending Stops"
|
||||
value={stats.pendingStops}
|
||||
icon={<MapPin className="w-4 h-4" />}
|
||||
tone="default"
|
||||
/>
|
||||
<KPIStat
|
||||
label="Active Products"
|
||||
value={usage.products}
|
||||
icon={<Package className="w-4 h-4" />}
|
||||
tone="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Command center: attention feed + quick actions ───── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
{/* Attention feed (left, 2 cols) */}
|
||||
<div className="lg:col-span-2 admin-card-section">
|
||||
<div className="admin-section-header">
|
||||
<span className="admin-section-title">What needs attention</span>
|
||||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{attentionItems.length} item{attentionItems.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
{attentionItems.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Sparkles className="w-8 h-8" />}
|
||||
title="All clear"
|
||||
description="Nothing needs your attention right now. Enjoy the calm."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||
{attentionItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const toneColor =
|
||||
item.tone === "accent" ? "var(--admin-accent)" :
|
||||
item.tone === "warning" ? "var(--admin-warning)" :
|
||||
item.tone === "danger" ? "var(--admin-danger)" :
|
||||
"var(--admin-primary)";
|
||||
const toneSoft =
|
||||
item.tone === "accent" ? "var(--admin-accent-soft)" :
|
||||
item.tone === "warning" ? "var(--admin-warning-soft)" :
|
||||
item.tone === "danger" ? "var(--admin-danger-soft)" :
|
||||
"var(--admin-primary-soft)";
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-black/[0.02]"
|
||||
>
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: toneSoft, color: toneColor }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
<svg className="w-4 h-4 mt-2 flex-shrink-0 opacity-30 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions (right, 1 col) */}
|
||||
<div className="admin-card-section">
|
||||
<div className="admin-section-header">
|
||||
<span className="admin-section-title">Quick actions</span>
|
||||
</div>
|
||||
<div className="admin-quick-actions">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
className="admin-quick-action"
|
||||
>
|
||||
<div className="admin-quick-action-icon" style={{ backgroundColor: "var(--admin-primary-soft)" }}>
|
||||
<Icon className="w-3.5 h-3.5" style={{ color: "var(--admin-primary)" }} />
|
||||
</div>
|
||||
<span className="admin-quick-action-label">{action.label}</span>
|
||||
<svg className="w-3.5 h-3.5 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Plan usage inside quick actions card */}
|
||||
<div className="px-4 py-3 border-t" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminBadge tone={planTier === "enterprise" ? "info" : planTier === "farm" ? "success" : "neutral"}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)}
|
||||
</AdminBadge>
|
||||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{brandId ? brandName : "All Brands"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
|
||||
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label} className="admin-usage-item">
|
||||
<div className="admin-usage-header">
|
||||
<span className="admin-usage-label">{label}</span>
|
||||
<span className="admin-usage-value ha-num">{value}</span>
|
||||
</div>
|
||||
<div className="admin-usage-bar">
|
||||
<div
|
||||
className="admin-usage-fill"
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "var(--admin-warning)" : "var(--admin-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Recent Orders ─────────────────────────────────────── */}
|
||||
<div className="admin-card-section">
|
||||
<div className="admin-section-header">
|
||||
<span className="admin-section-title">Recent orders</span>
|
||||
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-primary)" }}>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{stats.recentOrders && stats.recentOrders.length > 0 ? (
|
||||
<div className="admin-orders-list">
|
||||
{stats.recentOrders.slice(0, 6).map((order) => {
|
||||
const badge = getStatusBadge(order.status);
|
||||
return (
|
||||
<Link
|
||||
key={order.id}
|
||||
href={`/admin/orders`}
|
||||
className="admin-order-row"
|
||||
>
|
||||
<div className="admin-order-info">
|
||||
<div className="admin-order-icon">
|
||||
<ShoppingCart className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="admin-order-name">{order.customer_name}</span>
|
||||
<span className="admin-order-time">{order.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-order-meta">
|
||||
<span className="admin-order-amount ha-num">{formatCurrency(order.total)}</span>
|
||||
<span className="admin-order-badge" style={{ backgroundColor: badge.bg, color: badge.text }}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Inbox className="w-8 h-8" />}
|
||||
title="No recent orders"
|
||||
description="When customers place orders, they'll show up here."
|
||||
action={{ label: "Create your first order", href: "/admin/orders?new=true" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section filter + grid ────────────────────────────── */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||||
All sections
|
||||
</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Jump to any part of the admin. Press <kbd className="px-1.5 py-0.5 rounded text-[10px] font-mono" style={{ backgroundColor: "var(--admin-bg-subtle)", border: "1px solid var(--admin-border)" }}>⌘K</kbd> to search.
|
||||
</p>
|
||||
</div>
|
||||
<AdminFilterTabs
|
||||
activeTab={groupFilter}
|
||||
onTabChange={(value) => setGroupFilter(value as GroupFilter)}
|
||||
tabs={GROUP_FILTERS.map((f) => ({ value: f.id, label: f.label }))}
|
||||
size="sm"
|
||||
showCounts={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isAddon = Boolean(section.addonKey);
|
||||
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
|
||||
const isProminent = section.prominent;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className={[
|
||||
"admin-section-card",
|
||||
isAddon && !isEnabled ? "admin-section-card--locked" : "",
|
||||
isProminent ? "admin-section-card--prominent" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div className="admin-section-card-top">
|
||||
<div className={[
|
||||
"admin-section-card-icon",
|
||||
isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
|
||||
isProminent ? "admin-section-card-icon--prominent" : "",
|
||||
].filter(Boolean).join(" ")}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isAddon && !isEnabled && <AdminBadge tone="neutral">Add-on</AdminBadge>}
|
||||
{isAddon && isEnabled && <AdminBadge tone="success">Active</AdminBadge>}
|
||||
{isProminent && <AdminBadge tone="info">Core</AdminBadge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-section-card-body">
|
||||
<h3 className="admin-section-card-title">{section.title}</h3>
|
||||
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "" : ""].filter(Boolean).join(" ")}>
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAddon && !isEnabled && section.upgradeText && (
|
||||
<p className="admin-section-card-hint">{section.upgradeText}</p>
|
||||
)}
|
||||
|
||||
<div className="admin-section-card-arrow">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Modal — lazy-loaded via next/dynamic */}
|
||||
{planTier === "starter" && brandId && (
|
||||
<UpgradePlanModal
|
||||
isOpen={isUpgradeOpen}
|
||||
onClose={() => setIsUpgradeOpen(false)}
|
||||
isOpen={state.isUpgradeOpen}
|
||||
onClose={() => dispatch({ type: "CLOSE_UPGRADE" })}
|
||||
brandId={brandId}
|
||||
currentTier={planTier}
|
||||
/>
|
||||
@@ -488,3 +239,417 @@ export default function DashboardClient({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardHeader({
|
||||
brandName,
|
||||
planTier,
|
||||
brandId,
|
||||
onOpenUpgrade,
|
||||
}: {
|
||||
brandName: string;
|
||||
planTier: string;
|
||||
brandId: string | null;
|
||||
onOpenUpgrade: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6">
|
||||
<PageHeader
|
||||
icon={<Inbox className="h-5 w-5" />}
|
||||
title="Dashboard"
|
||||
subtitle={brandName}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/settings/billing" className="hidden sm:block text-xs font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Billing →
|
||||
</Link>
|
||||
{planTier === "starter" && brandId && (
|
||||
<AdminButton onClick={onOpenUpgrade} size="sm">
|
||||
Upgrade Plan
|
||||
</AdminButton>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="mb-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KPIStrip({
|
||||
stats,
|
||||
usage,
|
||||
}: {
|
||||
stats: DashboardStats;
|
||||
usage: { users: number; stops_this_month: number; products: number };
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KPIStat
|
||||
label="Today's Orders"
|
||||
value={stats.todayOrders}
|
||||
icon={<ShoppingCart className="w-4 h-4" />}
|
||||
tone="primary"
|
||||
/>
|
||||
<KPIStat
|
||||
label="Today's Revenue"
|
||||
value={formatCurrency(stats.todayRevenue)}
|
||||
icon={<DollarSign className="w-4 h-4" />}
|
||||
tone="accent"
|
||||
/>
|
||||
<KPIStat
|
||||
label="Pending Stops"
|
||||
value={stats.pendingStops}
|
||||
icon={<MapPin className="w-4 h-4" />}
|
||||
tone="default"
|
||||
/>
|
||||
<KPIStat
|
||||
label="Active Products"
|
||||
value={usage.products}
|
||||
icon={<Package className="w-4 h-4" />}
|
||||
tone="primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandCenter({
|
||||
attentionItems,
|
||||
planTier,
|
||||
brandId,
|
||||
brandName,
|
||||
usage,
|
||||
limits,
|
||||
usagePct,
|
||||
}: {
|
||||
attentionItems: AttentionItem[];
|
||||
planTier: string;
|
||||
brandId: string | null;
|
||||
brandName: string;
|
||||
usage: { users: number; stops_this_month: number; products: number };
|
||||
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
||||
usagePct: { users: number; stops: number; products: number };
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
<AttentionFeed items={attentionItems} />
|
||||
<QuickActionsPanel
|
||||
planTier={planTier}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
usage={usage}
|
||||
limits={limits}
|
||||
usagePct={usagePct}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttentionFeed({ items }: { items: AttentionItem[] }) {
|
||||
return (
|
||||
<div className="lg:col-span-2 admin-card-section">
|
||||
<div className="admin-section-header">
|
||||
<span className="admin-section-title">What needs attention</span>
|
||||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{items.length} item{items.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Sparkles className="w-8 h-8" />}
|
||||
title="All clear"
|
||||
description="Nothing needs your attention right now. Enjoy the calm."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const toneColor =
|
||||
item.tone === "accent" ? "var(--admin-accent)" :
|
||||
item.tone === "warning" ? "var(--admin-warning)" :
|
||||
item.tone === "danger" ? "var(--admin-danger)" :
|
||||
"var(--admin-primary)";
|
||||
const toneSoft =
|
||||
item.tone === "accent" ? "var(--admin-accent-soft)" :
|
||||
item.tone === "warning" ? "var(--admin-warning-soft)" :
|
||||
item.tone === "danger" ? "var(--admin-danger-soft)" :
|
||||
"var(--admin-primary-soft)";
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-black/[0.02]"
|
||||
>
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: toneSoft, color: toneColor }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
<svg className="w-4 h-4 mt-2 flex-shrink-0 opacity-30 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActionsPanel({
|
||||
planTier,
|
||||
brandId,
|
||||
brandName,
|
||||
usage,
|
||||
limits,
|
||||
usagePct,
|
||||
}: {
|
||||
planTier: string;
|
||||
brandId: string | null;
|
||||
brandName: string;
|
||||
usage: { users: number; stops_this_month: number; products: number };
|
||||
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
||||
usagePct: { users: number; stops: number; products: number };
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-card-section">
|
||||
<div className="admin-section-header">
|
||||
<span className="admin-section-title">Quick actions</span>
|
||||
</div>
|
||||
<div className="admin-quick-actions">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
className="admin-quick-action"
|
||||
>
|
||||
<div className="admin-quick-action-icon" style={{ backgroundColor: "var(--admin-primary-soft)" }}>
|
||||
<Icon className="w-3.5 h-3.5" style={{ color: "var(--admin-primary)" }} />
|
||||
</div>
|
||||
<span className="admin-quick-action-label">{action.label}</span>
|
||||
<svg className="w-3.5 h-3.5 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<PlanUsageCard
|
||||
planTier={planTier}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
usage={usage}
|
||||
limits={limits}
|
||||
usagePct={usagePct}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanUsageCard({
|
||||
planTier,
|
||||
brandId,
|
||||
brandName,
|
||||
usage,
|
||||
limits,
|
||||
usagePct,
|
||||
}: {
|
||||
planTier: string;
|
||||
brandId: string | null;
|
||||
brandName: string;
|
||||
usage: { users: number; stops_this_month: number; products: number };
|
||||
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
||||
usagePct: { users: number; stops: number; products: number };
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-t" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminBadge tone={planTier === "enterprise" ? "info" : planTier === "farm" ? "success" : "neutral"}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)}
|
||||
</AdminBadge>
|
||||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{brandId ? brandName : "All Brands"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
|
||||
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label} className="admin-usage-item">
|
||||
<div className="admin-usage-header">
|
||||
<span className="admin-usage-label">{label}</span>
|
||||
<span className="admin-usage-value ha-num">{value}</span>
|
||||
</div>
|
||||
<div className="admin-usage-bar">
|
||||
<div
|
||||
className="admin-usage-fill"
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "var(--admin-warning)" : "var(--admin-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentOrdersCard({
|
||||
orders,
|
||||
}: {
|
||||
orders: DashboardStats["recentOrders"];
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-card-section">
|
||||
<div className="admin-section-header">
|
||||
<span className="admin-section-title">Recent orders</span>
|
||||
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-primary)" }}>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{orders && orders.length > 0 ? (
|
||||
<div className="admin-orders-list">
|
||||
{orders.slice(0, 6).map((order) => {
|
||||
const badge = getStatusBadge(order.status);
|
||||
return (
|
||||
<Link
|
||||
key={order.id}
|
||||
href={`/admin/orders`}
|
||||
className="admin-order-row"
|
||||
>
|
||||
<div className="admin-order-info">
|
||||
<div className="admin-order-icon">
|
||||
<ShoppingCart className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="admin-order-name">{order.customer_name}</span>
|
||||
<span className="admin-order-time">{order.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-order-meta">
|
||||
<span className="admin-order-amount ha-num">{formatCurrency(order.total)}</span>
|
||||
<span className="admin-order-badge" style={{ backgroundColor: badge.bg, color: badge.text }}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Inbox className="w-8 h-8" />}
|
||||
title="No recent orders"
|
||||
description="When customers place orders, they'll show up here."
|
||||
action={{ label: "Create your first order", href: "/admin/orders?new=true" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionsGrid({
|
||||
groupFilter,
|
||||
visibleSections,
|
||||
enabledAddons,
|
||||
onChangeFilter,
|
||||
}: {
|
||||
groupFilter: GroupFilter;
|
||||
visibleSections: Section[];
|
||||
enabledAddons: Record<string, boolean>;
|
||||
onChangeFilter: (value: GroupFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||||
All sections
|
||||
</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Jump to any part of the admin. Press <kbd className="px-1.5 py-0.5 rounded text-[10px] font-mono" style={{ backgroundColor: "var(--admin-bg-subtle)", border: "1px solid var(--admin-border)" }}>⌘K</kbd> to search.
|
||||
</p>
|
||||
</div>
|
||||
<AdminFilterTabs
|
||||
activeTab={groupFilter}
|
||||
onTabChange={(value) => onChangeFilter(value as GroupFilter)}
|
||||
tabs={GROUP_FILTERS.map((f) => ({ value: f.id, label: f.label }))}
|
||||
size="sm"
|
||||
showCounts={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isAddon = Boolean(section.addonKey);
|
||||
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
|
||||
const isProminent = section.prominent;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className={[
|
||||
"admin-section-card",
|
||||
isAddon && !isEnabled ? "admin-section-card--locked" : "",
|
||||
isProminent ? "admin-section-card--prominent" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div className="admin-section-card-top">
|
||||
<div className={[
|
||||
"admin-section-card-icon",
|
||||
isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
|
||||
isProminent ? "admin-section-card-icon--prominent" : "",
|
||||
].filter(Boolean).join(" ")}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isAddon && !isEnabled && <AdminBadge tone="neutral">Add-on</AdminBadge>}
|
||||
{isAddon && isEnabled && <AdminBadge tone="success">Active</AdminBadge>}
|
||||
{isProminent && <AdminBadge tone="info">Core</AdminBadge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-section-card-body">
|
||||
<h3 className="admin-section-card-title">{section.title}</h3>
|
||||
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "" : ""].filter(Boolean).join(" ")}>
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAddon && !isEnabled && section.upgradeText && (
|
||||
<p className="admin-section-card-hint">{section.upgradeText}</p>
|
||||
)}
|
||||
|
||||
<div className="admin-section-card-arrow">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import Link from "next/link";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
import AdminBadge from "./design-system/AdminBadge";
|
||||
@@ -67,6 +67,82 @@ function shortId(id: string) {
|
||||
return id.slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
type State = {
|
||||
pendingOrders: Order[];
|
||||
pickedUpOrders: Order[];
|
||||
stops: Stop[];
|
||||
search: string;
|
||||
stopFilter: string;
|
||||
pickingUp: string | null;
|
||||
showPickedUp: boolean;
|
||||
pickupToast: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "PICKUP_START"; orderId: string }
|
||||
| {
|
||||
type: "PICKUP_SUCCESS";
|
||||
order: Order;
|
||||
pickup_completed_at: string | null;
|
||||
pickup_completed_by: string | null;
|
||||
}
|
||||
| { type: "PICKUP_DONE" }
|
||||
| { type: "SET_SEARCH"; value: string }
|
||||
| { type: "SET_STOP_FILTER"; value: string }
|
||||
| { type: "SET_SHOW_PICKED_UP"; value: boolean }
|
||||
| { type: "SET_TOAST"; value: string | null };
|
||||
|
||||
function initState(initial: {
|
||||
pendingOrders: Order[];
|
||||
pickedUpOrders: Order[];
|
||||
stops: Stop[];
|
||||
}): State {
|
||||
return {
|
||||
pendingOrders: initial.pendingOrders,
|
||||
pickedUpOrders: initial.pickedUpOrders,
|
||||
stops: initial.stops,
|
||||
search: "",
|
||||
stopFilter: "",
|
||||
pickingUp: null,
|
||||
showPickedUp: true,
|
||||
pickupToast: null,
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "PICKUP_START":
|
||||
return { ...state, pickingUp: action.orderId };
|
||||
case "PICKUP_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
pendingOrders: state.pendingOrders.filter((o) => o.id !== action.order.id),
|
||||
pickedUpOrders: [
|
||||
{
|
||||
...action.order,
|
||||
pickup_complete: true,
|
||||
pickup_completed_at: action.pickup_completed_at,
|
||||
pickup_completed_by: action.pickup_completed_by,
|
||||
},
|
||||
...state.pickedUpOrders,
|
||||
],
|
||||
pickupToast: action.order.id,
|
||||
};
|
||||
case "PICKUP_DONE":
|
||||
return { ...state, pickingUp: null };
|
||||
case "SET_SEARCH":
|
||||
return { ...state, search: action.value };
|
||||
case "SET_STOP_FILTER":
|
||||
return { ...state, stopFilter: action.value };
|
||||
case "SET_SHOW_PICKED_UP":
|
||||
return { ...state, showPickedUp: action.value };
|
||||
case "SET_TOAST":
|
||||
return { ...state, pickupToast: action.value };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function DriverPickupPanel({
|
||||
initialPendingOrders,
|
||||
initialPickedUpOrders,
|
||||
@@ -74,14 +150,21 @@ export default function DriverPickupPanel({
|
||||
brandId,
|
||||
canManagePickup,
|
||||
}: DriverPickupPanelProps) {
|
||||
const [pendingOrders, setPendingOrders] = useState<Order[]>(initialPendingOrders);
|
||||
const [pickedUpOrders, setPickedUpOrders] = useState<Order[]>(initialPickedUpOrders);
|
||||
const [stops] = useState<Stop[]>(initialStops);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stopFilter, setStopFilter] = useState("");
|
||||
const [pickingUp, setPickingUp] = useState<string | null>(null);
|
||||
const [showPickedUp, setShowPickedUp] = useState(true);
|
||||
const [pickupToast, setPickupToast] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
{ pendingOrders: initialPendingOrders, pickedUpOrders: initialPickedUpOrders, stops: initialStops },
|
||||
initState,
|
||||
);
|
||||
const {
|
||||
pendingOrders,
|
||||
pickedUpOrders,
|
||||
stops,
|
||||
search,
|
||||
stopFilter,
|
||||
pickingUp,
|
||||
showPickedUp,
|
||||
pickupToast,
|
||||
} = state;
|
||||
|
||||
const filteredPending = pendingOrders.filter((o) => {
|
||||
const matchesSearch =
|
||||
@@ -107,29 +190,24 @@ export default function DriverPickupPanel({
|
||||
|
||||
async function handleMarkPickup(orderId: string) {
|
||||
if (!canManagePickup) return;
|
||||
setPickingUp(orderId);
|
||||
dispatch({ type: "PICKUP_START", orderId });
|
||||
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
|
||||
if (result.success) {
|
||||
const order = pendingOrders.find((o) => o.id === orderId);
|
||||
if (order) {
|
||||
setPendingOrders((prev) => prev.filter((o) => o.id !== orderId));
|
||||
setPickedUpOrders((prev) => [
|
||||
{
|
||||
...order,
|
||||
pickup_complete: true,
|
||||
pickup_completed_at: result.pickup_completed_at,
|
||||
pickup_completed_by: result.pickup_completed_by,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setPickupToast(orderId);
|
||||
setTimeout(() => setPickupToast(null), 3000);
|
||||
dispatch({
|
||||
type: "PICKUP_SUCCESS",
|
||||
order,
|
||||
pickup_completed_at: result.pickup_completed_at,
|
||||
pickup_completed_by: result.pickup_completed_by,
|
||||
});
|
||||
setTimeout(() => dispatch({ type: "SET_TOAST", value: null }), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
setPickingUp(null);
|
||||
dispatch({ type: "PICKUP_DONE" });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -160,7 +238,7 @@ export default function DriverPickupPanel({
|
||||
<div className="flex gap-3">
|
||||
<select aria-label="Select"
|
||||
value={stopFilter}
|
||||
onChange={(e) => setStopFilter(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_STOP_FILTER", value: e.target.value })}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">All Stops</option>
|
||||
@@ -178,7 +256,7 @@ export default function DriverPickupPanel({
|
||||
type="search"
|
||||
placeholder="Search name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_SEARCH", value: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white pl-11 pr-4 py-3 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -213,7 +291,7 @@ export default function DriverPickupPanel({
|
||||
{/* Picked Up Toggle */}
|
||||
{hasAnyPickedUp && (
|
||||
<button type="button"
|
||||
onClick={() => setShowPickedUp((v) => !v)}
|
||||
onClick={() => dispatch({ type: "SET_SHOW_PICKED_UP", value: !showPickedUp })}
|
||||
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{showPickedUp ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useReducer, useCallback, useRef, useEffect, useState } from "react";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import { updateStop } from "@/actions/stops/update-stop";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
@@ -35,62 +35,101 @@ const PinIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
type FormFields = {
|
||||
city: string;
|
||||
stateField: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address: string;
|
||||
zip: string;
|
||||
cutoffTime: string;
|
||||
status: "draft" | "active";
|
||||
};
|
||||
|
||||
const [city, setCity] = useState("");
|
||||
const [stateField, setStateField] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [zip, setZip] = useState("");
|
||||
const [cutoffTime, setCutoffTime] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||
type State = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
form: FormFields;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_LOADING"; loading: boolean }
|
||||
| { type: "SET_ERROR"; error: string | null }
|
||||
| { type: "SET_FIELD"; field: keyof FormFields; value: string }
|
||||
| { type: "RESET_FORM" }
|
||||
| { type: "POPULATE_FROM_STOP"; stop: Stop };
|
||||
|
||||
const emptyForm: FormFields = {
|
||||
city: "",
|
||||
stateField: "",
|
||||
location: "",
|
||||
date: "",
|
||||
time: "",
|
||||
address: "",
|
||||
zip: "",
|
||||
cutoffTime: "",
|
||||
status: "draft",
|
||||
};
|
||||
|
||||
const initialState: State = {
|
||||
loading: false,
|
||||
error: null,
|
||||
form: { ...emptyForm },
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.loading };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.error };
|
||||
case "SET_FIELD":
|
||||
return { ...state, form: { ...state.form, [action.field]: action.value } };
|
||||
case "RESET_FORM":
|
||||
return { ...state, error: null, form: { ...emptyForm } };
|
||||
case "POPULATE_FROM_STOP":
|
||||
return {
|
||||
...state,
|
||||
error: null,
|
||||
form: {
|
||||
city: action.stop.city,
|
||||
stateField: action.stop.state,
|
||||
location: action.stop.location,
|
||||
date: action.stop.date,
|
||||
time: action.stop.time || "",
|
||||
address: action.stop.address ?? "",
|
||||
zip: action.stop.zip ?? "",
|
||||
cutoffTime: action.stop.cutoff_time ? action.stop.cutoff_time.slice(0, 16) : "",
|
||||
status: action.stop.active ? "active" : "draft",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const cityRef = useRef<HTMLInputElement>(null);
|
||||
const isEditing = Boolean(stop);
|
||||
|
||||
// Reset form when modal opens or stop changes — derived during render
|
||||
// per React docs: "Adjusting some state when a prop changes".
|
||||
// Sync form fields when modal opens or stop changes.
|
||||
// We track the last-seen key in a ref so we only dispatch when it changes,
|
||||
// and the effect runs after commit so the form is populated before focus.
|
||||
const prevSyncKeyRef = useRef<string | null>(null);
|
||||
const syncKey = `${isOpen ? "open" : "closed"}:${stop?.id ?? "new"}`;
|
||||
if (syncKey !== prevSyncKeyRef.current) {
|
||||
useEffect(() => {
|
||||
if (syncKey === prevSyncKeyRef.current) return;
|
||||
prevSyncKeyRef.current = syncKey;
|
||||
if (isOpen) {
|
||||
if (stop) {
|
||||
// Edit mode - populate from existing stop.
|
||||
setCity(stop.city);
|
||||
setStateField(stop.state);
|
||||
setLocation(stop.location);
|
||||
setDate(stop.date);
|
||||
setTime(stop.time || "");
|
||||
setAddress(stop.address ?? "");
|
||||
setZip(stop.zip ?? "");
|
||||
setCutoffTime(stop.cutoff_time ? stop.cutoff_time.slice(0, 16) : "");
|
||||
setStatus(stop.active ? "active" : "draft");
|
||||
} else {
|
||||
// Add mode - reset form
|
||||
setCity("");
|
||||
setStateField("");
|
||||
setLocation("");
|
||||
setDate("");
|
||||
setTime("");
|
||||
setAddress("");
|
||||
setZip("");
|
||||
setCutoffTime("");
|
||||
setStatus("draft");
|
||||
}
|
||||
setError(null);
|
||||
if (!isOpen) return;
|
||||
if (stop) {
|
||||
dispatch({ type: "POPULATE_FROM_STOP", stop });
|
||||
} else {
|
||||
dispatch({ type: "RESET_FORM" });
|
||||
}
|
||||
}
|
||||
}, [syncKey, isOpen, stop]);
|
||||
|
||||
// Focus the first field once the modal has actually opened. This is
|
||||
// a DOM side-effect, so it must run after the commit (i.e. in an
|
||||
// effect) — accessing `cityRef.current` during render is illegal.
|
||||
// The `prevSyncKeyRef.current?.startsWith("open:")` guard keeps the focus
|
||||
// call to the transition into the open state, not every re-render.
|
||||
// Focus the first field once the modal has actually opened.
|
||||
useEffect(() => {
|
||||
if (prevSyncKeyRef.current?.startsWith("open:")) {
|
||||
const id = requestAnimationFrame(() => cityRef.current?.focus());
|
||||
@@ -102,14 +141,15 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
dispatch({ type: "SET_ERROR", error: null });
|
||||
|
||||
const { city, stateField, location, date, time, address, zip, cutoffTime, status } = state.form;
|
||||
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||
setError("City, state, location, and date are required.");
|
||||
dispatch({ type: "SET_ERROR", error: "City, state, location, and date are required." });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch({ type: "SET_LOADING", loading: true });
|
||||
try {
|
||||
let result;
|
||||
if (isEditing && stop) {
|
||||
@@ -143,46 +183,31 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
|
||||
onSuccess?.(newStopId);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? `Failed to ${isEditing ? "update" : "create"} stop`);
|
||||
dispatch({ type: "SET_ERROR", error: result.error ?? `Failed to ${isEditing ? "update" : "create"} stop` });
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
dispatch({ type: "SET_ERROR", error: "Network error. Please try again." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_LOADING", loading: false });
|
||||
}
|
||||
},
|
||||
[
|
||||
brandId,
|
||||
city,
|
||||
stateField,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
address,
|
||||
zip,
|
||||
cutoffTime,
|
||||
status,
|
||||
isEditing,
|
||||
stop,
|
||||
onSuccess,
|
||||
onClose,
|
||||
]
|
||||
[brandId, state.form, isEditing, stop, onSuccess, onClose]
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const title = isEditing ? "Edit Stop" : "Add Stop";
|
||||
const eyebrow = isEditing
|
||||
? `${stop?.city}, ${stop?.state}`
|
||||
: "New stop on the route";
|
||||
const submitLabel = loading
|
||||
const submitLabel = state.loading
|
||||
? isEditing ? "Saving…" : "Creating…"
|
||||
: isEditing
|
||||
? "Save Changes"
|
||||
: status === "active"
|
||||
: state.form.status === "active"
|
||||
? "Create & Publish"
|
||||
: "Save as Draft";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title={title}
|
||||
@@ -192,257 +217,393 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
|
||||
compact
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<ModalError error={state.error} />
|
||||
|
||||
{/* Row 1 — Where: City + State */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-city" className="ha-field-label">
|
||||
<PinIcon />
|
||||
<span>City</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Denver"
|
||||
ref={cityRef}
|
||||
id="stop-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
className="ha-field-input"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-state" className="ha-field-label">
|
||||
<span>State</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="CO"
|
||||
id="stop-state"
|
||||
type="text"
|
||||
value={stateField}
|
||||
onChange={(e) => setStateField(e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="ha-field-input ha-field-input-mono text-center"
|
||||
style={{ textTransform: "uppercase" }}
|
||||
required
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<WhereFields
|
||||
cityRef={cityRef}
|
||||
city={state.form.city}
|
||||
stateField={state.form.stateField}
|
||||
onField={(field, value) => dispatch({ type: "SET_FIELD", field, value })}
|
||||
/>
|
||||
|
||||
{/* Row 2 — Where at: Location (full) */}
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-location" className="ha-field-label">
|
||||
<span>Location / Venue</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Whole Foods Market — Highlands"
|
||||
id="stop-location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market — Highlands"
|
||||
className="ha-field-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<LocationField
|
||||
location={state.form.location}
|
||||
onChange={(value) => dispatch({ type: "SET_FIELD", field: "location", value })}
|
||||
/>
|
||||
|
||||
{/* Row 3 — When: Date + Time */}
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-date" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Date</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Stop Date"
|
||||
id="stop-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-time" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Time</span>
|
||||
</label>
|
||||
<input aria-label="Stop Time"
|
||||
id="stop-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<WhenFields
|
||||
date={state.form.date}
|
||||
time={state.form.time}
|
||||
onField={(field, value) => dispatch({ type: "SET_FIELD", field, value })}
|
||||
/>
|
||||
|
||||
{/* Row 4 — Address + ZIP + Cutoff */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-address" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Street Address</span>
|
||||
</label>
|
||||
<input aria-label="123 Main St"
|
||||
id="stop-address"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="ha-field-input"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-zip" className="ha-field-label">
|
||||
<span>ZIP</span>
|
||||
</label>
|
||||
<input aria-label="80202"
|
||||
id="stop-zip"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
maxLength={10}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
autoComplete="postal-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Cutoff</span>
|
||||
</label>
|
||||
<input aria-label="Stop Cutoff"
|
||||
id="stop-cutoff"
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AddressFields
|
||||
address={state.form.address}
|
||||
zip={state.form.zip}
|
||||
cutoffTime={state.form.cutoffTime}
|
||||
onField={(field, value) => dispatch({ type: "SET_FIELD", field, value })}
|
||||
/>
|
||||
|
||||
{/* Row 5 — Status: segmented control */}
|
||||
<div className="ha-field pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="ha-field-label">
|
||||
<span>Visibility</span>
|
||||
</p>
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||
Draft is hidden from customers
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "draft"}
|
||||
onClick={() => setStatus("draft")}
|
||||
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Save as Draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "active"}
|
||||
onClick={() => setStatus("active")}
|
||||
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Publish Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<StatusSegment
|
||||
status={state.form.status}
|
||||
onChange={(value) => dispatch({ type: "SET_FIELD", field: "status", value })}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="ha-modal-footer">
|
||||
<span className="ha-modal-footer-hint">
|
||||
<kbd>Esc</kbd>
|
||||
to close
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : status === "active" ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ModalFooter
|
||||
loading={state.loading}
|
||||
isEditing={isEditing}
|
||||
status={state.form.status}
|
||||
submitLabel={submitLabel}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal Error ───────────────────────────────────────────────────────────────
|
||||
function ModalError({ error }: { error: string | null }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Where Fields (city + state) ───────────────────────────────────────────────
|
||||
function WhereFields({
|
||||
cityRef,
|
||||
city,
|
||||
stateField,
|
||||
onField,
|
||||
}: {
|
||||
cityRef: React.RefObject<HTMLInputElement | null>;
|
||||
city: string;
|
||||
stateField: string;
|
||||
onField: (field: "city" | "stateField", value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-city" className="ha-field-label">
|
||||
<PinIcon />
|
||||
<span>City</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Denver"
|
||||
ref={cityRef}
|
||||
id="stop-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => onField("city", e.target.value)}
|
||||
placeholder="Denver"
|
||||
className="ha-field-input"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-state" className="ha-field-label">
|
||||
<span>State</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="CO"
|
||||
id="stop-state"
|
||||
type="text"
|
||||
value={stateField}
|
||||
onChange={(e) => onField("stateField", e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="ha-field-input ha-field-input-mono text-center"
|
||||
style={{ textTransform: "uppercase" }}
|
||||
required
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Location Field ────────────────────────────────────────────────────────────
|
||||
function LocationField({
|
||||
location,
|
||||
onChange,
|
||||
}: {
|
||||
location: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-location" className="ha-field-label">
|
||||
<span>Location / Venue</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Whole Foods Market — Highlands"
|
||||
id="stop-location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Whole Foods Market — Highlands"
|
||||
className="ha-field-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── When Fields (date + time) ─────────────────────────────────────────────────
|
||||
function WhenFields({
|
||||
date,
|
||||
time,
|
||||
onField,
|
||||
}: {
|
||||
date: string;
|
||||
time: string;
|
||||
onField: (field: "date" | "time", value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-date" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Date</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input aria-label="Stop Date"
|
||||
id="stop-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => onField("date", e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-time" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Time</span>
|
||||
</label>
|
||||
<input aria-label="Stop Time"
|
||||
id="stop-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => onField("time", e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Address Fields (address + zip + cutoff) ───────────────────────────────────
|
||||
function AddressFields({
|
||||
address,
|
||||
zip,
|
||||
cutoffTime,
|
||||
onField,
|
||||
}: {
|
||||
address: string;
|
||||
zip: string;
|
||||
cutoffTime: string;
|
||||
onField: (field: "address" | "zip" | "cutoffTime", value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-address" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Street Address</span>
|
||||
</label>
|
||||
<input aria-label="123 Main St"
|
||||
id="stop-address"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => onField("address", e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="ha-field-input"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-zip" className="ha-field-label">
|
||||
<span>ZIP</span>
|
||||
</label>
|
||||
<input aria-label="80202"
|
||||
id="stop-zip"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => onField("zip", e.target.value)}
|
||||
placeholder="80202"
|
||||
maxLength={10}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
autoComplete="postal-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Cutoff</span>
|
||||
</label>
|
||||
<input aria-label="Stop Cutoff"
|
||||
id="stop-cutoff"
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => onField("cutoffTime", e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status Segment ────────────────────────────────────────────────────────────
|
||||
function StatusSegment({
|
||||
status,
|
||||
onChange,
|
||||
}: {
|
||||
status: "draft" | "active";
|
||||
onChange: (value: "draft" | "active") => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ha-field pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="ha-field-label">
|
||||
<span>Visibility</span>
|
||||
</p>
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||
Draft is hidden from customers
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "draft"}
|
||||
onClick={() => onChange("draft")}
|
||||
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Save as Draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "active"}
|
||||
onClick={() => onChange("active")}
|
||||
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Publish Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal Footer ──────────────────────────────────────────────────────────────
|
||||
function ModalFooter({
|
||||
loading,
|
||||
isEditing,
|
||||
status,
|
||||
submitLabel,
|
||||
onClose,
|
||||
}: {
|
||||
loading: boolean;
|
||||
isEditing: boolean;
|
||||
status: "draft" | "active";
|
||||
submitLabel: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ha-modal-footer">
|
||||
<span className="ha-modal-footer-hint">
|
||||
<kbd>Esc</kbd>
|
||||
to close
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SubmitIcon isEditing={isEditing} status={status} />
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitIcon({
|
||||
isEditing,
|
||||
status,
|
||||
}: {
|
||||
isEditing: boolean;
|
||||
status: "draft" | "active";
|
||||
}) {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (status === "active") {
|
||||
return (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for easy integration
|
||||
function useEditStopModal(brandId: string) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -480,4 +641,4 @@ function useEditStopModal(brandId: string) {
|
||||
), [isOpen, close, brandId, editingStop]);
|
||||
|
||||
return { open, close, Modal, editingStop };
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import { type Segment, type SegmentRuleV2 } from "@/actions/harvest-reach/segments";
|
||||
import SegmentBuilderPanel from "./SegmentBuilderPanel";
|
||||
import MatchingCustomersPanel from "./MatchingCustomersPanel";
|
||||
@@ -45,9 +45,9 @@ function SegmentsEmptyState({ onNew }: { onNew: () => void }) {
|
||||
<p className="mt-3 text-sm text-stone-500 max-w-sm leading-relaxed">
|
||||
Create segments to organize your contacts and send targeted campaigns to specific audiences.
|
||||
</p>
|
||||
<AdminButton
|
||||
onClick={onNew}
|
||||
className="mt-8"
|
||||
<AdminButton
|
||||
onClick={onNew}
|
||||
className="mt-8"
|
||||
icon={<PlusIcon className="w-4 h-4" />}
|
||||
>
|
||||
Create Your First Segment
|
||||
@@ -59,7 +59,7 @@ function SegmentsEmptyState({ onNew }: { onNew: () => void }) {
|
||||
// Active segment header with refined design
|
||||
function ActiveSegmentHeader({ segment, onClear, customerCount }: { segment: Segment | null; onClear: () => void; customerCount?: number }) {
|
||||
if (!segment) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 rounded-2xl bg-gradient-to-r from-emerald-50 to-teal-50 border border-emerald-200 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -94,74 +94,113 @@ function ActiveSegmentHeader({ segment, onClear, customerCount }: { segment: Seg
|
||||
);
|
||||
}
|
||||
|
||||
type State = {
|
||||
segments: Segment[];
|
||||
activeSegment: Segment | null;
|
||||
currentRules: SegmentRuleV2;
|
||||
showEditModal: boolean;
|
||||
customerCount: number | undefined;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SELECT_SEGMENT"; segment: Segment }
|
||||
| { type: "OPEN_NEW_SEGMENT" }
|
||||
| { type: "CLEAR_SELECTION" }
|
||||
| { type: "SET_RULES"; rules: SegmentRuleV2 }
|
||||
| { type: "SET_SHOW_EDIT_MODAL"; value: boolean }
|
||||
| { type: "SET_CUSTOMER_COUNT"; count: number }
|
||||
| { type: "UPSERT_SUCCESS"; segment: Segment }
|
||||
| { type: "DELETE_SEGMENT"; segmentId: string };
|
||||
|
||||
function buildInitialState(initialSegments: Segment[]): State {
|
||||
return {
|
||||
segments: initialSegments,
|
||||
activeSegment: null,
|
||||
currentRules: { combinator: "AND", filters: [] },
|
||||
showEditModal: false,
|
||||
customerCount: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SELECT_SEGMENT":
|
||||
return {
|
||||
...state,
|
||||
activeSegment: action.segment,
|
||||
currentRules: action.segment.rules as SegmentRuleV2,
|
||||
};
|
||||
case "OPEN_NEW_SEGMENT":
|
||||
return {
|
||||
...state,
|
||||
activeSegment: null,
|
||||
currentRules: { combinator: "AND", filters: [] },
|
||||
showEditModal: true,
|
||||
};
|
||||
case "CLEAR_SELECTION":
|
||||
return {
|
||||
...state,
|
||||
activeSegment: null,
|
||||
currentRules: { combinator: "AND", filters: [] },
|
||||
customerCount: undefined,
|
||||
};
|
||||
case "SET_RULES":
|
||||
return { ...state, currentRules: action.rules };
|
||||
case "SET_SHOW_EDIT_MODAL":
|
||||
return { ...state, showEditModal: action.value };
|
||||
case "SET_CUSTOMER_COUNT":
|
||||
return { ...state, customerCount: action.count };
|
||||
case "UPSERT_SUCCESS": {
|
||||
const wasUpdate = state.activeSegment !== null;
|
||||
const segments = wasUpdate
|
||||
? state.segments.map((s) => (s.id === action.segment.id ? action.segment : s))
|
||||
: [...state.segments, action.segment];
|
||||
return {
|
||||
...state,
|
||||
segments,
|
||||
activeSegment: action.segment,
|
||||
showEditModal: false,
|
||||
};
|
||||
}
|
||||
case "DELETE_SEGMENT": {
|
||||
const segments = state.segments.filter((s) => s.id !== action.segmentId);
|
||||
if (state.activeSegment?.id === action.segmentId) {
|
||||
return {
|
||||
...state,
|
||||
segments,
|
||||
activeSegment: null,
|
||||
currentRules: { combinator: "AND", filters: [] },
|
||||
customerCount: undefined,
|
||||
};
|
||||
}
|
||||
return { ...state, segments };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
|
||||
const [segments, setSegments] = useState<Segment[]>(initialSegments);
|
||||
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
|
||||
const [currentRules, setCurrentRules] = useState<SegmentRuleV2>({
|
||||
combinator: "AND",
|
||||
filters: [],
|
||||
});
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [customerCount, setCustomerCount] = useState<number | undefined>(undefined);
|
||||
|
||||
function handleSegmentSelect(segment: Segment) {
|
||||
setActiveSegment(segment);
|
||||
setCurrentRules(segment.rules as SegmentRuleV2);
|
||||
}
|
||||
|
||||
function handleNewSegment() {
|
||||
setActiveSegment(null);
|
||||
setCurrentRules({ combinator: "AND", filters: [] });
|
||||
setShowEditModal(true);
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
setActiveSegment(null);
|
||||
setCurrentRules({ combinator: "AND", filters: [] });
|
||||
setCustomerCount(undefined);
|
||||
}
|
||||
|
||||
function handleRulesChange(rules: SegmentRuleV2) {
|
||||
setCurrentRules(rules);
|
||||
}
|
||||
|
||||
function handleCustomerCount(count: number) {
|
||||
setCustomerCount(count);
|
||||
}
|
||||
const [state, dispatch] = useReducer(reducer, initialSegments, buildInitialState);
|
||||
|
||||
async function handleSaveSegment(name: string, description: string) {
|
||||
const { upsertHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
|
||||
const result = await upsertHarvestReachSegment({
|
||||
id: activeSegment?.id,
|
||||
id: state.activeSegment?.id,
|
||||
brand_id: brandId,
|
||||
name,
|
||||
description,
|
||||
rules: currentRules,
|
||||
rules: state.currentRules,
|
||||
});
|
||||
if (result.success) {
|
||||
if (activeSegment) {
|
||||
setSegments((prev) =>
|
||||
prev.map((s) => (s.id === result.segment.id ? result.segment : s))
|
||||
);
|
||||
} else {
|
||||
setSegments((prev) => [...prev, result.segment]);
|
||||
}
|
||||
setActiveSegment(result.segment);
|
||||
setShowEditModal(false);
|
||||
dispatch({ type: "UPSERT_SUCCESS", segment: result.segment });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSegment(segmentId: string) {
|
||||
const { deleteHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
|
||||
await deleteHarvestReachSegment(segmentId, brandId);
|
||||
setSegments((prev) => prev.filter((s) => s.id !== segmentId));
|
||||
if (activeSegment?.id === segmentId) {
|
||||
handleClearSelection();
|
||||
}
|
||||
dispatch({ type: "DELETE_SEGMENT", segmentId });
|
||||
}
|
||||
|
||||
const hasFilters = currentRules.filters.length > 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -176,22 +215,22 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
</div>
|
||||
|
||||
{/* Show empty state if no segments */}
|
||||
{segments.length === 0 ? (
|
||||
<SegmentsEmptyState onNew={handleNewSegment} />
|
||||
{state.segments.length === 0 ? (
|
||||
<SegmentsEmptyState onNew={() => dispatch({ type: "OPEN_NEW_SEGMENT" })} />
|
||||
) : (
|
||||
<>
|
||||
{/* Active segment indicator */}
|
||||
<ActiveSegmentHeader segment={activeSegment} onClear={handleClearSelection} customerCount={customerCount} />
|
||||
<ActiveSegmentHeader segment={state.activeSegment} onClear={() => dispatch({ type: "CLEAR_SELECTION" })} customerCount={state.customerCount} />
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Left sidebar */}
|
||||
<div className="lg:w-72 flex-shrink-0">
|
||||
<SegmentListSidebar
|
||||
segments={segments}
|
||||
activeSegmentId={activeSegment?.id}
|
||||
onSelect={handleSegmentSelect}
|
||||
onNew={handleNewSegment}
|
||||
segments={state.segments}
|
||||
activeSegmentId={state.activeSegment?.id}
|
||||
onSelect={(segment) => dispatch({ type: "SELECT_SEGMENT", segment })}
|
||||
onNew={() => dispatch({ type: "OPEN_NEW_SEGMENT" })}
|
||||
onDelete={handleDeleteSegment}
|
||||
/>
|
||||
</div>
|
||||
@@ -200,27 +239,27 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<SegmentBuilderPanel
|
||||
brandId={brandId}
|
||||
rules={currentRules}
|
||||
onChange={handleRulesChange}
|
||||
onSave={() => setShowEditModal(true)}
|
||||
hasActiveSegment={!!activeSegment}
|
||||
rules={state.currentRules}
|
||||
onChange={(rules) => dispatch({ type: "SET_RULES", rules })}
|
||||
onSave={() => dispatch({ type: "SET_SHOW_EDIT_MODAL", value: true })}
|
||||
hasActiveSegment={!!state.activeSegment}
|
||||
/>
|
||||
<MatchingCustomersPanel
|
||||
brandId={brandId}
|
||||
rules={currentRules}
|
||||
onCountChange={handleCustomerCount}
|
||||
<MatchingCustomersPanel
|
||||
brandId={brandId}
|
||||
rules={state.currentRules}
|
||||
onCountChange={(count) => dispatch({ type: "SET_CUSTOMER_COUNT", count })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEditModal && (
|
||||
{state.showEditModal && (
|
||||
<SegmentEditModal
|
||||
initialName={activeSegment?.name ?? ""}
|
||||
initialDescription={activeSegment?.description ?? ""}
|
||||
initialName={state.activeSegment?.name ?? ""}
|
||||
initialDescription={state.activeSegment?.description ?? ""}
|
||||
onSave={handleSaveSegment}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
onClose={() => dispatch({ type: "SET_SHOW_EDIT_MODAL", value: false })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useReducer, useEffect } from "react";
|
||||
import { sendStopBlast } from "@/actions/communications/stop-blast";
|
||||
import {
|
||||
getStopMessagingData,
|
||||
@@ -8,6 +8,155 @@ import {
|
||||
type StopBlastMessage,
|
||||
} from "@/actions/communications/stop-messaging";
|
||||
|
||||
type Audience = "all" | "pending" | "picked_up";
|
||||
type Channel = "sms" | "email" | "both";
|
||||
|
||||
type LoadData = {
|
||||
orders: StopOrder[];
|
||||
messages: StopBlastMessage[];
|
||||
error: string | null;
|
||||
loadingOrders: boolean;
|
||||
loadingMessages: boolean;
|
||||
};
|
||||
|
||||
type State = {
|
||||
audience: Audience;
|
||||
channel: Channel;
|
||||
subject: string;
|
||||
body: string;
|
||||
sending: boolean;
|
||||
confirm: string | null;
|
||||
loadData: LoadData;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_AUDIENCE"; value: Audience }
|
||||
| { type: "SET_CHANNEL"; value: Channel }
|
||||
| { type: "SET_SUBJECT"; value: string }
|
||||
| { type: "SET_BODY"; value: string }
|
||||
| { type: "SET_SENDING"; value: boolean }
|
||||
| { type: "SET_CONFIRM"; value: string | null }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SEND_START" }
|
||||
| { type: "SEND_SUCCESS"; messagesLogged: number }
|
||||
| { type: "SEND_FAIL"; error: string }
|
||||
| { type: "LOAD_RESET" }
|
||||
| { type: "LOAD_SUCCESS"; orders: StopOrder[]; messages: StopBlastMessage[] }
|
||||
| { type: "LOAD_FAIL"; error: string }
|
||||
| { type: "REFRESH_MESSAGES_START" }
|
||||
| { type: "REFRESH_MESSAGES_SUCCESS"; messages: StopBlastMessage[] }
|
||||
| { type: "REFRESH_MESSAGES_FAIL" };
|
||||
|
||||
const initialState: State = {
|
||||
audience: "pending",
|
||||
channel: "sms",
|
||||
subject: "",
|
||||
body: "",
|
||||
sending: false,
|
||||
confirm: null,
|
||||
loadData: {
|
||||
orders: [],
|
||||
messages: [],
|
||||
error: null,
|
||||
loadingOrders: true,
|
||||
loadingMessages: true,
|
||||
},
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_AUDIENCE":
|
||||
return { ...state, audience: action.value };
|
||||
case "SET_CHANNEL":
|
||||
return { ...state, channel: action.value };
|
||||
case "SET_SUBJECT":
|
||||
return { ...state, subject: action.value };
|
||||
case "SET_BODY":
|
||||
return { ...state, body: action.value };
|
||||
case "SET_SENDING":
|
||||
return { ...state, sending: action.value };
|
||||
case "SET_CONFIRM":
|
||||
return { ...state, confirm: action.value };
|
||||
case "SET_ERROR":
|
||||
return {
|
||||
...state,
|
||||
loadData: { ...state.loadData, error: action.value },
|
||||
};
|
||||
case "SEND_START":
|
||||
return {
|
||||
...state,
|
||||
sending: true,
|
||||
confirm: null,
|
||||
loadData: { ...state.loadData, error: null },
|
||||
};
|
||||
case "SEND_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
sending: false,
|
||||
subject: "",
|
||||
body: "",
|
||||
confirm: `Blast sent — ${action.messagesLogged} message${action.messagesLogged !== 1 ? "s" : ""} logged via campaign.`,
|
||||
};
|
||||
case "SEND_FAIL":
|
||||
return {
|
||||
...state,
|
||||
sending: false,
|
||||
loadData: { ...state.loadData, error: action.error },
|
||||
};
|
||||
case "LOAD_RESET":
|
||||
return {
|
||||
...state,
|
||||
loadData: {
|
||||
orders: [],
|
||||
messages: [],
|
||||
error: null,
|
||||
loadingOrders: true,
|
||||
loadingMessages: true,
|
||||
},
|
||||
};
|
||||
case "LOAD_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
loadData: {
|
||||
...state.loadData,
|
||||
orders: action.orders,
|
||||
messages: action.messages,
|
||||
loadingOrders: false,
|
||||
loadingMessages: false,
|
||||
},
|
||||
};
|
||||
case "LOAD_FAIL":
|
||||
return {
|
||||
...state,
|
||||
loadData: {
|
||||
...state.loadData,
|
||||
error: action.error,
|
||||
loadingOrders: false,
|
||||
loadingMessages: false,
|
||||
},
|
||||
};
|
||||
case "REFRESH_MESSAGES_START":
|
||||
return {
|
||||
...state,
|
||||
loadData: { ...state.loadData, loadingMessages: true },
|
||||
};
|
||||
case "REFRESH_MESSAGES_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
loadData: {
|
||||
...state.loadData,
|
||||
loadingMessages: false,
|
||||
messages: action.messages,
|
||||
},
|
||||
};
|
||||
case "REFRESH_MESSAGES_FAIL":
|
||||
return {
|
||||
...state,
|
||||
loadData: { ...state.loadData, loadingMessages: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type MessageCustomersSectionProps = {
|
||||
stopId: string;
|
||||
brandId?: string;
|
||||
@@ -17,49 +166,15 @@ export default function MessageCustomersSection({
|
||||
stopId,
|
||||
brandId,
|
||||
}: MessageCustomersSectionProps) {
|
||||
const [audience, setAudience] = useState<"all" | "pending" | "picked_up">("pending");
|
||||
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [confirm, setConfirm] = useState<string | null>(null);
|
||||
|
||||
// Group the load-state into a single object so the effect only
|
||||
// writes one piece of state at a time (satisfies
|
||||
// `no-cascading-set-state`).
|
||||
type LoadState = {
|
||||
orders: StopOrder[];
|
||||
messages: StopBlastMessage[];
|
||||
error: string | null;
|
||||
loadingOrders: boolean;
|
||||
loadingMessages: boolean;
|
||||
};
|
||||
const [loadData, setLoadData] = useState<LoadState>({
|
||||
orders: [],
|
||||
messages: [],
|
||||
error: null,
|
||||
loadingOrders: true,
|
||||
loadingMessages: true,
|
||||
});
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { audience, channel, subject, body, sending, confirm, loadData } = state;
|
||||
const { orders, messages, error, loadingOrders, loadingMessages } = loadData;
|
||||
const setError = (v: string | null) => setLoadData((prev) => ({ ...prev, error: v }));
|
||||
|
||||
// Track the last (stopId, brandId) tuple we kicked off a fetch for.
|
||||
// When the prop changes we flip `loadingOrders` + `loadingMessages`
|
||||
// inline during render so users never see stale "loaded" UI between
|
||||
// the prop change and the effect running.
|
||||
const lastFetchKeyRef = useRef<string | null>(null);
|
||||
const fetchKey = `${stopId}|${brandId ?? ""}`;
|
||||
if (fetchKey !== lastFetchKeyRef.current) {
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
setLoadData({
|
||||
orders: [],
|
||||
messages: [],
|
||||
error: null,
|
||||
loadingOrders: true,
|
||||
loadingMessages: true,
|
||||
});
|
||||
}
|
||||
// When (stopId, brandId) changes, reset to the loading state so users
|
||||
// don't see stale "loaded" data from the previous stop.
|
||||
useEffect(() => {
|
||||
dispatch({ type: "LOAD_RESET" });
|
||||
}, [stopId, brandId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -67,9 +182,13 @@ export default function MessageCustomersSection({
|
||||
const res = await getStopMessagingData({ stopId, brandId });
|
||||
if (cancelled) return;
|
||||
if (res.success) {
|
||||
setLoadData((prev) => ({ ...prev, orders: res.orders, messages: res.messages, loadingOrders: false, loadingMessages: false }));
|
||||
dispatch({
|
||||
type: "LOAD_SUCCESS",
|
||||
orders: res.orders,
|
||||
messages: res.messages,
|
||||
});
|
||||
} else {
|
||||
setLoadData((prev) => ({ ...prev, error: res.error, loadingOrders: false, loadingMessages: false }));
|
||||
dispatch({ type: "LOAD_FAIL", error: res.error });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -99,17 +218,21 @@ export default function MessageCustomersSection({
|
||||
e.preventDefault();
|
||||
if (!body.trim()) return;
|
||||
if (showSubject && !subject.trim()) {
|
||||
setError("Subject is required for email or both.");
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
value: "Subject is required for email or both.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!brandId) {
|
||||
setError("Brand ID not available.");
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
value: "Brand ID not available.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
setConfirm(null);
|
||||
dispatch({ type: "SEND_START" });
|
||||
|
||||
const result = await sendStopBlast({
|
||||
stopId,
|
||||
@@ -120,22 +243,25 @@ export default function MessageCustomersSection({
|
||||
audience,
|
||||
});
|
||||
|
||||
setSending(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to send blast");
|
||||
dispatch({
|
||||
type: "SEND_FAIL",
|
||||
error: result.error ?? "Failed to send blast",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setConfirm(`Blast sent — ${result.messages_logged} message${result.messages_logged !== 1 ? "s" : ""} logged via campaign.`);
|
||||
setBody("");
|
||||
setSubject("");
|
||||
dispatch({
|
||||
type: "SEND_SUCCESS",
|
||||
messagesLogged: result.messages_logged,
|
||||
});
|
||||
|
||||
// Refresh the message log
|
||||
setLoadData((prev) => ({ ...prev, loadingMessages: true }));
|
||||
dispatch({ type: "REFRESH_MESSAGES_START" });
|
||||
const res = await getStopMessagingData({ stopId, brandId });
|
||||
if (res.success) {
|
||||
setLoadData((prev) => ({ ...prev, loadingMessages: false, messages: res.messages }));
|
||||
dispatch({ type: "REFRESH_MESSAGES_SUCCESS", messages: res.messages });
|
||||
} else {
|
||||
setLoadData((prev) => ({ ...prev, loadingMessages: false }));
|
||||
dispatch({ type: "REFRESH_MESSAGES_FAIL" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,131 +284,193 @@ export default function MessageCustomersSection({
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">No orders for this stop yet.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Audience selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300" htmlFor="fld-audience---recipientslength-with-contact">
|
||||
Audience —{" "}
|
||||
<span className="text-zinc-500">
|
||||
{recipients.length} with contact info
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["all", "pending", "picked_up"] as const).map((a) => {
|
||||
const count =
|
||||
a === "all"
|
||||
? allOrders.length
|
||||
: a === "pending"
|
||||
? pendingOrders.length
|
||||
: pickedUpOrders.length;
|
||||
return (
|
||||
<button type="button"
|
||||
key={a}
|
||||
onClick={() => setAudience(a)}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium capitalize transition-colors ${
|
||||
audience === a
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{a.replace("_", " ")} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300" htmlFor="fld-channel">
|
||||
Channel
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["sms", "email", "both"] as const).map((ch) => (
|
||||
<button type="button"
|
||||
key={ch}
|
||||
onClick={() => setChannel(ch)}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
|
||||
channel === ch
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSend} className="space-y-4">
|
||||
{showSubject && (
|
||||
<div>
|
||||
<label htmlFor="fld-1-subject" className="mb-1 block text-sm font-medium text-zinc-300">Subject</label>
|
||||
<input id="fld-1-subject" aria-label="Important Update About Your Pickup"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Important update about your pickup"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-2-message" className="mb-1 block text-sm font-medium text-zinc-300">Message</label>
|
||||
<textarea id="fld-2-message" aria-label="Type Your Message Here..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
placeholder="Type your message here..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">{body.length} characters</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!body.trim() || recipients.length === 0 || sending}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{sending
|
||||
? "Sending..."
|
||||
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
<MessageForm
|
||||
audience={audience}
|
||||
channel={channel}
|
||||
subject={subject}
|
||||
body={body}
|
||||
sending={sending}
|
||||
allOrders={allOrders}
|
||||
pendingOrders={pendingOrders}
|
||||
pickedUpOrders={pickedUpOrders}
|
||||
recipientsLength={recipients.length}
|
||||
showSubject={showSubject}
|
||||
onSelectAudience={(a) => dispatch({ type: "SET_AUDIENCE", value: a })}
|
||||
onSelectChannel={(ch) => dispatch({ type: "SET_CHANNEL", value: ch })}
|
||||
onSubjectChange={(v) => dispatch({ type: "SET_SUBJECT", value: v })}
|
||||
onBodyChange={(v) => dispatch({ type: "SET_BODY", value: v })}
|
||||
onSubmit={handleSend}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recent message history */}
|
||||
{!loadingMessages && messages.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-medium text-zinc-300">
|
||||
Recent Messages
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-zinc-100">
|
||||
{msg.type.toUpperCase()}{" "}
|
||||
{msg.subject && <span className="text-zinc-500">— {msg.subject}</span>}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-zinc-400">{msg.body}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{new Date(msg.created_at).toLocaleString()} ·{" "}
|
||||
{msg.message_recipients?.length ?? 0} recipients
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<MessageHistory messages={messages} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageFormProps = {
|
||||
audience: Audience;
|
||||
channel: Channel;
|
||||
subject: string;
|
||||
body: string;
|
||||
sending: boolean;
|
||||
allOrders: StopOrder[];
|
||||
pendingOrders: StopOrder[];
|
||||
pickedUpOrders: StopOrder[];
|
||||
recipientsLength: number;
|
||||
showSubject: boolean;
|
||||
onSelectAudience: (a: Audience) => void;
|
||||
onSelectChannel: (ch: Channel) => void;
|
||||
onSubjectChange: (v: string) => void;
|
||||
onBodyChange: (v: string) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
};
|
||||
|
||||
function MessageForm({
|
||||
audience,
|
||||
channel,
|
||||
subject,
|
||||
body,
|
||||
sending,
|
||||
allOrders,
|
||||
pendingOrders,
|
||||
pickedUpOrders,
|
||||
recipientsLength,
|
||||
showSubject,
|
||||
onSelectAudience,
|
||||
onSelectChannel,
|
||||
onSubjectChange,
|
||||
onBodyChange,
|
||||
onSubmit,
|
||||
}: MessageFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Audience selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300" htmlFor="fld-audience---recipientslength-with-contact">
|
||||
Audience —{" "}
|
||||
<span className="text-zinc-500">
|
||||
{recipientsLength} with contact info
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["all", "pending", "picked_up"] as const).map((a) => {
|
||||
const count =
|
||||
a === "all"
|
||||
? allOrders.length
|
||||
: a === "pending"
|
||||
? pendingOrders.length
|
||||
: pickedUpOrders.length;
|
||||
return (
|
||||
<button type="button"
|
||||
key={a}
|
||||
onClick={() => onSelectAudience(a)}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium capitalize transition-colors ${
|
||||
audience === a
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{a.replace("_", " ")} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300" htmlFor="fld-channel">
|
||||
Channel
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["sms", "email", "both"] as const).map((ch) => (
|
||||
<button type="button"
|
||||
key={ch}
|
||||
onClick={() => onSelectChannel(ch)}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
|
||||
channel === ch
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{showSubject && (
|
||||
<div>
|
||||
<label htmlFor="fld-1-subject" className="mb-1 block text-sm font-medium text-zinc-300">Subject</label>
|
||||
<input id="fld-1-subject" aria-label="Important Update About Your Pickup"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => onSubjectChange(e.target.value)}
|
||||
placeholder="Important update about your pickup"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-2-message" className="mb-1 block text-sm font-medium text-zinc-300">Message</label>
|
||||
<textarea id="fld-2-message" aria-label="Type Your Message Here..."
|
||||
value={body}
|
||||
onChange={(e) => onBodyChange(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
placeholder="Type your message here..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">{body.length} characters</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!body.trim() || recipientsLength === 0 || sending}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{sending
|
||||
? "Sending..."
|
||||
: `Send to ${recipientsLength} customer${recipientsLength !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageHistory({ messages }: { messages: StopBlastMessage[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-medium text-zinc-300">
|
||||
Recent Messages
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-zinc-100">
|
||||
{msg.type.toUpperCase()}{" "}
|
||||
{msg.subject && <span className="text-zinc-500">— {msg.subject}</span>}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-zinc-400">{msg.body}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{new Date(msg.created_at).toLocaleString()} ·{" "}
|
||||
{msg.message_recipients?.length ?? 0} recipients
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useEffect, useCallback, useRef, useReducer } from "react";
|
||||
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import AdminButton from "./design-system/AdminButton";
|
||||
@@ -167,11 +166,11 @@ function MethodBadge({ method }: { method: string }) {
|
||||
// Engagement indicator
|
||||
function EngagementIndicator({ log }: { log: MessageLogEntry }) {
|
||||
const hasEngagement = log.delivered_at || log.opened_at || log.clicked_at || log.bounced_at;
|
||||
|
||||
|
||||
if (!hasEngagement) {
|
||||
return <span className="text-xs text-stone-400">No engagement yet</span>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{log.delivered_at && (
|
||||
@@ -204,16 +203,54 @@ function EngagementIndicator({ log }: { log: MessageLogEntry }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- useReducer state ----
|
||||
type State = {
|
||||
logs: MessageLogEntry[];
|
||||
search: string;
|
||||
statusFilter: string;
|
||||
page: number;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_LOGS"; logs: MessageLogEntry[] }
|
||||
| { type: "SET_SEARCH"; value: string }
|
||||
| { type: "SET_STATUS_FILTER"; value: string }
|
||||
| { type: "SET_PAGE"; value: number }
|
||||
| { type: "SET_LOADING"; value: boolean }
|
||||
| { type: "RESET_PAGE" }
|
||||
| { type: "RESET_FOR_FETCH" };
|
||||
|
||||
const initialState: State = {
|
||||
logs: [],
|
||||
search: "",
|
||||
statusFilter: "all",
|
||||
page: 1,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_LOGS":
|
||||
return { ...state, logs: action.logs };
|
||||
case "SET_SEARCH":
|
||||
return { ...state, search: action.value, page: 1 };
|
||||
case "SET_STATUS_FILTER":
|
||||
return { ...state, statusFilter: action.value, page: 1 };
|
||||
case "SET_PAGE":
|
||||
return { ...state, page: action.value };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.value };
|
||||
case "RESET_PAGE":
|
||||
return { ...state, page: 1 };
|
||||
case "RESET_FOR_FETCH":
|
||||
return { ...state, isLoading: true, logs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const [logs, setLogs] = useState<MessageLogEntry[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
// Whether a fetch is currently in flight. Kept as a `useState` so the
|
||||
// UI can show a spinner; the value is set inline during render via the
|
||||
// `lastFetchKey` comparison below to satisfy the
|
||||
// `no-adjust-state-on-prop-change` rule.
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { logs, search, statusFilter, page, isLoading } = state;
|
||||
// Track the last (brandId|statusFilter) signature we kicked off a
|
||||
// fetch for. We adjust `isLoading` + `logs` inline during render when
|
||||
// the signature changes, so users never see a stale "loaded" UI
|
||||
@@ -222,22 +259,23 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const fetchKey = brandId ? `${brandId}|${statusFilter}` : null;
|
||||
if (fetchKey !== lastFetchKeyRef.current) {
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
setIsLoading(Boolean(fetchKey));
|
||||
setLogs([]);
|
||||
if (fetchKey) {
|
||||
dispatch({ type: "RESET_FOR_FETCH" });
|
||||
}
|
||||
}
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
if (!brandId) return;
|
||||
setIsLoading(true);
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
const result = await getMessageLogs({
|
||||
brandId,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 100,
|
||||
});
|
||||
if (result.success) {
|
||||
setLogs(result.logs);
|
||||
dispatch({ type: "SET_LOGS", logs: result.logs });
|
||||
}
|
||||
setIsLoading(false);
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
}, [brandId, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -267,7 +305,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const paginatedLogs = filteredLogs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setPage(1);
|
||||
dispatch({ type: "RESET_PAGE" });
|
||||
// Re-trigger the data load by resetting the fetch key — the inline
|
||||
// `if (fetchKey !== lastFetchKeyRef.current)` block will re-run on
|
||||
// the next render and reset isLoading + logs.
|
||||
@@ -279,220 +317,296 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
{Icons.messageSquare("h-6 h-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-stone-900">Message Logs</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
{isLoading ? "Loading..." : `${filteredLogs.length} message${filteredLogs.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AdminButton variant="secondary" onClick={handleRefresh} disabled={isLoading} icon={Icons.refresh("w-4 h-4" + (isLoading ? " animate-spin" : ""))}>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-stone-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Total</p>
|
||||
<p className="text-2xl font-bold text-stone-800 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-emerald-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Delivered</p>
|
||||
<p className="text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-red-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-blue-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Pending</p>
|
||||
<p className="text-2xl font-bold text-blue-600 mt-1">{stats.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
|
||||
{Icons.search("h-5 w-5 text-stone-400")}
|
||||
</div>
|
||||
<input aria-label="Search By Email Or Subject..."
|
||||
type="text"
|
||||
placeholder="Search by email or subject..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select aria-label="Select"
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="opened">Opened</option>
|
||||
<option value="clicked">Clicked</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="overflow-hidden rounded-xl border border-stone-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isLoading ? (
|
||||
<tbody className="bg-white" aria-busy="true"><tr><td colSpan={6}><LogSkeleton /></td></tr></tbody>
|
||||
) : null}
|
||||
</table>
|
||||
</div>
|
||||
) : paginatedLogs.length === 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-stone-200">
|
||||
<LogsEmptyState hasFilters={hasFilters} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-stone-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100 bg-white">
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs whitespace-nowrap">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-800 text-sm font-medium">
|
||||
{log.customer_email ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<MethodBadge method={log.delivery_method} />
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-700 text-sm truncate max-w-[180px]" title={log.subject ?? undefined}>
|
||||
{log.subject ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<StatusBadge status={log.status} />
|
||||
{log.error_message && (
|
||||
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[120px]" title={log.error_message}>
|
||||
{log.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<EngagementIndicator log={log} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<div key={log.id} className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-stone-800">{log.customer_email ?? "—"}</div>
|
||||
<div className="text-xs text-stone-500 mt-0.5 truncate max-w-[200px]">{log.subject ?? "—"}</div>
|
||||
</div>
|
||||
<StatusBadge status={log.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MethodBadge method={log.delivery_method} />
|
||||
<span className="text-xs text-stone-500">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<EngagementIndicator log={log} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
|
||||
<p className="text-sm text-stone-500">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredLogs.length)} of {filteredLogs.length}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Previous">
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<button type="button"
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`px-4 py-2 text-sm border rounded-xl transition-colors ${
|
||||
page === pageNum
|
||||
? "bg-emerald-600 text-white border-emerald-600"
|
||||
: "bg-white text-stone-700 border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Next">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<MessageLogHeader
|
||||
isLoading={isLoading}
|
||||
filteredCount={filteredLogs.length}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<StatsCards stats={stats} />
|
||||
<FiltersRow
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
onSearchChange={(value) => dispatch({ type: "SET_SEARCH", value })}
|
||||
onStatusChange={(value) => dispatch({ type: "SET_STATUS_FILTER", value })}
|
||||
/>
|
||||
<MessageLogTable
|
||||
isLoading={isLoading}
|
||||
paginatedLogs={paginatedLogs}
|
||||
hasFilters={hasFilters}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
filteredCount={filteredLogs.length}
|
||||
onSetPage={(p) => dispatch({ type: "SET_PAGE", value: p })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageLogHeaderProps = {
|
||||
isLoading: boolean;
|
||||
filteredCount: number;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
function MessageLogHeader({ isLoading, filteredCount, onRefresh }: MessageLogHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
{Icons.messageSquare("h-6 h-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-stone-900">Message Logs</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
{isLoading ? "Loading..." : `${filteredCount} message${filteredCount !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AdminButton variant="secondary" onClick={onRefresh} disabled={isLoading} icon={Icons.refresh("w-4 h-4" + (isLoading ? " animate-spin" : ""))}>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatsCardsProps = {
|
||||
stats: { total: number; delivered: number; failed: number; pending: number };
|
||||
};
|
||||
|
||||
function StatsCards({ stats }: StatsCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-stone-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Total</p>
|
||||
<p className="text-2xl font-bold text-stone-800 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-emerald-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Delivered</p>
|
||||
<p className="text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-red-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-blue-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Pending</p>
|
||||
<p className="text-2xl font-bold text-blue-600 mt-1">{stats.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FiltersRowProps = {
|
||||
search: string;
|
||||
statusFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
};
|
||||
|
||||
function FiltersRow({ search, statusFilter, onSearchChange, onStatusChange }: FiltersRowProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
|
||||
{Icons.search("h-5 w-5 text-stone-400")}
|
||||
</div>
|
||||
<input aria-label="Search By Email Or Subject..."
|
||||
type="text"
|
||||
placeholder="Search by email or subject..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select aria-label="Select"
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="opened">Opened</option>
|
||||
<option value="clicked">Clicked</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageLogTableProps = {
|
||||
isLoading: boolean;
|
||||
paginatedLogs: MessageLogEntry[];
|
||||
hasFilters: boolean;
|
||||
};
|
||||
|
||||
function MessageLogTable({ isLoading, paginatedLogs, hasFilters }: MessageLogTableProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-stone-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white" aria-busy="true"><tr><td colSpan={6}><LogSkeleton /></td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (paginatedLogs.length === 0) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-stone-200">
|
||||
<LogsEmptyState hasFilters={hasFilters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DesktopTable logs={paginatedLogs} />
|
||||
<MobileCards logs={paginatedLogs} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopTable({ logs }: { logs: MessageLogEntry[] }) {
|
||||
return (
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-stone-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100 bg-white">
|
||||
{logs.map((log: MessageLogEntry) => (
|
||||
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs whitespace-nowrap">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-800 text-sm font-medium">
|
||||
{log.customer_email ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<MethodBadge method={log.delivery_method} />
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-700 text-sm truncate max-w-[180px]" title={log.subject ?? undefined}>
|
||||
{log.subject ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<StatusBadge status={log.status} />
|
||||
{log.error_message && (
|
||||
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[120px]" title={log.error_message}>
|
||||
{log.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<EngagementIndicator log={log} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCards({ logs }: { logs: MessageLogEntry[] }) {
|
||||
return (
|
||||
<div className="sm:hidden space-y-3">
|
||||
{logs.map((log: MessageLogEntry) => (
|
||||
<div key={log.id} className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-stone-800">{log.customer_email ?? "—"}</div>
|
||||
<div className="text-xs text-stone-500 mt-0.5 truncate max-w-[200px]">{log.subject ?? "—"}</div>
|
||||
</div>
|
||||
<StatusBadge status={log.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MethodBadge method={log.delivery_method} />
|
||||
<span className="text-xs text-stone-500">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<EngagementIndicator log={log} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
filteredCount: number;
|
||||
onSetPage: (page: number) => void;
|
||||
};
|
||||
|
||||
function Pagination({ page, totalPages, filteredCount, onSetPage }: PaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
|
||||
<p className="text-sm text-stone-500">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredCount)} of {filteredCount}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button type="button"
|
||||
onClick={() => onSetPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Previous">
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<button type="button"
|
||||
key={pageNum}
|
||||
onClick={() => onSetPage(pageNum)}
|
||||
className={`px-4 py-2 text-sm border rounded-xl transition-colors ${
|
||||
page === pageNum
|
||||
? "bg-emerald-600 text-white border-emerald-600"
|
||||
: "bg-white text-stone-700 border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button type="button"
|
||||
onClick={() => onSetPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Next">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import NextImage from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState, useRef } from "react";
|
||||
import { useReducer, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import { createProduct } from "@/actions/products/create-product";
|
||||
@@ -43,27 +43,209 @@ type Props = {
|
||||
|
||||
const EMPTY_BRANDS: { id: string; name: string }[] = [];
|
||||
|
||||
// --- Reducer state -------------------------------------------------------
|
||||
|
||||
type State = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
imagePreview: string | null;
|
||||
uploading: boolean;
|
||||
uploadError: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
type: string;
|
||||
brandId: string;
|
||||
isTaxable: string;
|
||||
pickupType: string;
|
||||
active: string;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_LOADING"; value: boolean }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SET_IMAGE_PREVIEW"; value: string | null }
|
||||
| { type: "SET_UPLOADING"; value: boolean }
|
||||
| { type: "SET_UPLOAD_ERROR"; value: string | null }
|
||||
| { type: "SET_FIELD"; field: "name" | "description" | "price" | "type" | "brandId" | "isTaxable" | "pickupType" | "active"; value: string };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SET_IMAGE_PREVIEW":
|
||||
return { ...state, imagePreview: action.value };
|
||||
case "SET_UPLOADING":
|
||||
return { ...state, uploading: action.value };
|
||||
case "SET_UPLOAD_ERROR":
|
||||
return { ...state, uploadError: action.value };
|
||||
case "SET_FIELD":
|
||||
return { ...state, [action.field]: action.value };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function createInitialState(defaultBrandId: string): State {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
imagePreview: null,
|
||||
uploading: false,
|
||||
uploadError: null,
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "Pickup",
|
||||
brandId: defaultBrandId,
|
||||
isTaxable: "true",
|
||||
pickupType: "scheduled_stop",
|
||||
active: "true",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Subcomponent: image uploader ---------------------------------------
|
||||
|
||||
type ImageUploaderProps = {
|
||||
imagePreview: string | null;
|
||||
uploading: boolean;
|
||||
uploadError: string | null;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onFileSelect: (file: File) => void;
|
||||
onRemoveImage: () => void;
|
||||
};
|
||||
|
||||
function ImageUploader({
|
||||
imagePreview,
|
||||
uploading,
|
||||
uploadError,
|
||||
fileInputRef,
|
||||
onFileSelect,
|
||||
onRemoveImage,
|
||||
}: ImageUploaderProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
|
||||
<label
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) onFileSelect(file);
|
||||
}}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<>
|
||||
<span className="relative inline-block h-32 w-auto">
|
||||
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input aria-label="File upload"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
||||
|
||||
{imagePreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemoveImage}
|
||||
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Subcomponent: brand selector ---------------------------------------
|
||||
|
||||
type BrandSelectorProps = {
|
||||
brandId: string;
|
||||
brandOptions: { id: string; name: string }[];
|
||||
brands: { id: string; name: string }[];
|
||||
lockBrand: boolean;
|
||||
defaultBrandId: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
function BrandSelector({
|
||||
brandId,
|
||||
brandOptions,
|
||||
brands,
|
||||
lockBrand,
|
||||
defaultBrandId,
|
||||
onChange,
|
||||
}: BrandSelectorProps) {
|
||||
const brandSelectOptions = brandOptions.map((b) => ({ value: b.id, label: b.name }));
|
||||
return (
|
||||
<AdminInput label="Brand" required>
|
||||
{lockBrand ? (
|
||||
// brand_admin / store_employee — brand is fixed by their admin_users record
|
||||
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
|
||||
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
|
||||
</div>
|
||||
) : (
|
||||
<AdminSelect
|
||||
value={brandId}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: brands.length > 0 ? "Select brand..." : "Loading brands..." },
|
||||
...brandSelectOptions,
|
||||
]}
|
||||
disabled={brands.length === 0 && defaultBrandId === ""}
|
||||
/>
|
||||
)}
|
||||
{!lockBrand && brands.length === 0 && (
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
Loading available brands — if this persists, check the admin Brands settings.
|
||||
</p>
|
||||
)}
|
||||
</AdminInput>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main component ------------------------------------------------------
|
||||
|
||||
export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRANDS, lockBrand = false }: Props) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, createInitialState(defaultBrandId));
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingImageUrlRef = useRef<string>("");
|
||||
const setPendingImageUrl = (v: string) => { pendingImageUrlRef.current = v; };
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [type, setType] = useState("Pickup");
|
||||
const [brandId, setBrandId] = useState(defaultBrandId);
|
||||
const [isTaxable, setIsTaxable] = useState("true");
|
||||
const [pickupType, setPickupType] = useState("scheduled_stop");
|
||||
const [active, setActive] = useState("true");
|
||||
|
||||
// Build the brand options. If the server provided a list, use it; otherwise
|
||||
// fall back to the historical hardcoded list so the form still works in
|
||||
// environments where getBrands() failed or returned empty.
|
||||
@@ -73,16 +255,15 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
|
||||
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" },
|
||||
];
|
||||
const brandSelectOptions = brandOptions.map((b) => ({ value: b.id, label: b.name }));
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
||||
dispatch({ type: "SET_UPLOAD_ERROR", value: "Only PNG, JPEG, and WebP images are allowed." });
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Image must be under 5MB.");
|
||||
dispatch({ type: "SET_UPLOAD_ERROR", value: "Image must be under 5MB." });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,41 +271,41 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
dispatch({ type: "SET_UPLOAD_ERROR", value: null });
|
||||
dispatch({ type: "SET_UPLOADING", value: true });
|
||||
const result = await uploadProductImage("__NEW__", resizedFile);
|
||||
setUploading(false);
|
||||
dispatch({ type: "SET_UPLOADING", value: false });
|
||||
|
||||
if (result.success) {
|
||||
setPendingImageUrl(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
dispatch({ type: "SET_IMAGE_PREVIEW", value: result.imageUrl });
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
dispatch({ type: "SET_UPLOAD_ERROR", value: result.error ?? "Upload failed." });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
|
||||
// Use pendingImageUrl if image was uploaded, otherwise null
|
||||
const imageUrl = pendingImageUrlRef.current || null;
|
||||
|
||||
const result = await createProduct(brandId, {
|
||||
name,
|
||||
description,
|
||||
price: parseFloat(price),
|
||||
type,
|
||||
active: active === "true",
|
||||
const result = await createProduct(state.brandId, {
|
||||
name: state.name,
|
||||
description: state.description,
|
||||
price: parseFloat(state.price),
|
||||
type: state.type,
|
||||
active: state.active === "true",
|
||||
image_url: imageUrl,
|
||||
is_taxable: isTaxable === "true",
|
||||
pickup_type: pickupType,
|
||||
is_taxable: state.isTaxable === "true",
|
||||
pickup_type: state.pickupType,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to create product");
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to create product" });
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,18 +313,23 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
function handleRemoveImage() {
|
||||
dispatch({ type: "SET_IMAGE_PREVIEW", value: null });
|
||||
setPendingImageUrl("");
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
|
||||
{error && (
|
||||
{state.error && (
|
||||
<div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-[var(--admin-danger)] text-sm">
|
||||
{error}
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminInput label="Product Name" required>
|
||||
<AdminTextInput
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={state.name}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "name", value: e.target.value })}
|
||||
placeholder="e.g. Dozen Sweet Corn"
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -151,8 +337,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
|
||||
<AdminInput label="Description">
|
||||
<AdminTextarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
value={state.description}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "description", value: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="e.g. Fresh-picked Olathe sweet corn."
|
||||
/>
|
||||
@@ -163,16 +349,16 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
<AdminTextInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
value={state.price}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "price", value: e.target.value })}
|
||||
placeholder="12.00"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Type" required>
|
||||
<AdminSelect
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
value={state.type}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "type", value: e.target.value })}
|
||||
options={[
|
||||
{ value: "Pickup", label: "Pickup" },
|
||||
{ value: "Shipping", label: "Shipping" },
|
||||
@@ -182,34 +368,19 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<AdminInput label="Brand" required>
|
||||
{lockBrand ? (
|
||||
// brand_admin / store_employee — brand is fixed by their admin_users record
|
||||
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
|
||||
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
|
||||
</div>
|
||||
) : (
|
||||
<AdminSelect
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: brands.length > 0 ? "Select brand..." : "Loading brands..." },
|
||||
...brandSelectOptions,
|
||||
]}
|
||||
disabled={brands.length === 0 && defaultBrandId === ""}
|
||||
/>
|
||||
)}
|
||||
{!lockBrand && brands.length === 0 && (
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
Loading available brands — if this persists, check the admin Brands settings.
|
||||
</p>
|
||||
)}
|
||||
</AdminInput>
|
||||
<BrandSelector
|
||||
brandId={state.brandId}
|
||||
brandOptions={brandOptions}
|
||||
brands={brands}
|
||||
lockBrand={lockBrand}
|
||||
defaultBrandId={defaultBrandId}
|
||||
onChange={(v) => dispatch({ type: "SET_FIELD", field: "brandId", value: v })}
|
||||
/>
|
||||
|
||||
<AdminInput label="Taxable" helpText="Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.">
|
||||
<AdminSelect
|
||||
value={isTaxable}
|
||||
onChange={(e) => setIsTaxable(e.target.value)}
|
||||
value={state.isTaxable}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "isTaxable", value: e.target.value })}
|
||||
options={[
|
||||
{ value: "true", label: "Yes — taxable (default)" },
|
||||
{ value: "false", label: "No — non-taxable" },
|
||||
@@ -219,8 +390,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
|
||||
<AdminInput label="Pickup Type" helpText="Shed pickup uses the product description as the location — no stop required at checkout.">
|
||||
<AdminSelect
|
||||
value={pickupType}
|
||||
onChange={(e) => setPickupType(e.target.value)}
|
||||
value={state.pickupType}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "pickupType", value: e.target.value })}
|
||||
options={[
|
||||
{ value: "scheduled_stop", label: "Scheduled Stop — requires stop selection at checkout" },
|
||||
{ value: "shed", label: "Shed Pickup — uses description as location" },
|
||||
@@ -230,8 +401,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
|
||||
<AdminInput label="Active">
|
||||
<AdminSelect
|
||||
value={active}
|
||||
onChange={(e) => setActive(e.target.value)}
|
||||
value={state.active}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "active", value: e.target.value })}
|
||||
options={[
|
||||
{ value: "true", label: "Yes — show on storefront" },
|
||||
{ value: "false", label: "No — hide from storefront" },
|
||||
@@ -239,76 +410,23 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<div className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
|
||||
<label
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<>
|
||||
<span className="relative inline-block h-32 w-auto">
|
||||
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input aria-label="File upload"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
||||
|
||||
{imagePreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
|
||||
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ImageUploader
|
||||
imagePreview={state.imagePreview}
|
||||
uploading={state.uploading}
|
||||
uploadError={state.uploadError}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
/>
|
||||
|
||||
{/* Save button bar — new design tokens */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={state.loading}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Product"}
|
||||
{state.loading ? "Creating..." : "Create Product"}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
@@ -25,87 +25,120 @@ type Props = {
|
||||
duplicateFrom?: Stop | null;
|
||||
};
|
||||
|
||||
type FormFields = {
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
brandId: string;
|
||||
active: string;
|
||||
address: string;
|
||||
zip: string;
|
||||
cutoffTime: string;
|
||||
};
|
||||
|
||||
type State = FormFields & {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fieldErrors: Record<string, string>;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_FIELD"; field: keyof FormFields; value: string }
|
||||
| { type: "SET_FIELD_ERRORS"; errors: Record<string, string> }
|
||||
| { type: "SET_LOADING"; value: boolean }
|
||||
| { type: "SET_ERROR"; error: string | null };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_FIELD": {
|
||||
const nextErrors = { ...state.fieldErrors };
|
||||
delete nextErrors[action.field];
|
||||
return {
|
||||
...state,
|
||||
[action.field]: action.value,
|
||||
fieldErrors: nextErrors,
|
||||
};
|
||||
}
|
||||
case "SET_FIELD_ERRORS":
|
||||
return { ...state, fieldErrors: action.errors };
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.error };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_BRAND = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
function validate(s: State): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!s.city.trim()) errors.city = "City is required";
|
||||
if (!s.state.trim()) errors.state = "State is required";
|
||||
if (!s.location.trim()) errors.location = "Location is required";
|
||||
if (!s.date.trim()) errors.date = "Date is required";
|
||||
if (!s.time.trim()) errors.time = "Time is required";
|
||||
if (!s.brandId) errors.brandId = "Brand is required";
|
||||
return errors;
|
||||
}
|
||||
|
||||
export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const defaultBrand = duplicateFrom?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
// Form state
|
||||
const [city, setCity] = useState(duplicateFrom?.city ?? "");
|
||||
const [state, setState] = useState(duplicateFrom?.state ?? "");
|
||||
const [location, setLocation] = useState(duplicateFrom?.location ?? "");
|
||||
const [date, setDate] = useState(duplicateFrom?.date ?? "");
|
||||
const [time, setTime] = useState(duplicateFrom?.time ?? "");
|
||||
const [brandId, setBrandId] = useState(defaultBrand);
|
||||
const [active, setActive] = useState("true");
|
||||
const [address, setAddress] = useState(duplicateFrom?.address ?? "");
|
||||
const [zip, setZip] = useState(duplicateFrom?.zip ?? "");
|
||||
const [cutoffTime, setCutoffTime] = useState(duplicateFrom?.cutoff_time ?? "");
|
||||
|
||||
// Validation errors
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
function validateForm(): boolean {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!city.trim()) {
|
||||
errors.city = "City is required";
|
||||
}
|
||||
if (!state.trim()) {
|
||||
errors.state = "State is required";
|
||||
}
|
||||
if (!location.trim()) {
|
||||
errors.location = "Location is required";
|
||||
}
|
||||
if (!date.trim()) {
|
||||
errors.date = "Date is required";
|
||||
}
|
||||
if (!time.trim()) {
|
||||
errors.time = "Time is required";
|
||||
}
|
||||
if (!brandId) {
|
||||
errors.brandId = "Brand is required";
|
||||
}
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
const [state, dispatch] = useReducer(reducer, duplicateFrom ?? null, (df): State => ({
|
||||
city: df?.city ?? "",
|
||||
state: df?.state ?? "",
|
||||
location: df?.location ?? "",
|
||||
date: df?.date ?? "",
|
||||
time: df?.time ?? "",
|
||||
brandId: df?.brand_id ?? DEFAULT_BRAND,
|
||||
active: "true",
|
||||
address: df?.address ?? "",
|
||||
zip: df?.zip ?? "",
|
||||
cutoffTime: df?.cutoff_time ?? "",
|
||||
loading: false,
|
||||
error: null,
|
||||
fieldErrors: {},
|
||||
}));
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
|
||||
const errors = validate(state);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
dispatch({ type: "SET_FIELD_ERRORS", errors });
|
||||
showError("Validation failed", "Please fix the errors below");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
dispatch({ type: "SET_ERROR", error: null });
|
||||
|
||||
const result = await createStop(brandId, {
|
||||
city,
|
||||
state,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
active: active === "true",
|
||||
address: address || null,
|
||||
zip: zip || null,
|
||||
cutoff_time: cutoffTime || null,
|
||||
const result = await createStop(state.brandId, {
|
||||
city: state.city,
|
||||
state: state.state,
|
||||
location: state.location,
|
||||
date: state.date,
|
||||
time: state.time,
|
||||
active: state.active === "true",
|
||||
address: state.address || null,
|
||||
zip: state.zip || null,
|
||||
cutoff_time: state.cutoffTime || null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
showError("Failed to create stop", result.error ?? "Please try again");
|
||||
setError(result.error ?? "Failed to create stop");
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_ERROR", error: result.error ?? "Failed to create stop" });
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess("Stop created", `${city}, ${state} has been added`);
|
||||
|
||||
showSuccess("Stop created", `${state.city}, ${state.state} has been added`);
|
||||
|
||||
if (result.id) {
|
||||
router.push(`/admin/stops/${result.id}`);
|
||||
} else {
|
||||
@@ -116,236 +149,330 @@ export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{state.error && <ErrorBanner message={state.error} />}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-city" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
City <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-city" aria-label=". Denver"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => {
|
||||
setCity(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.city;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. Denver"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.city
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.city && <p className="mt-1 text-xs text-red-500">{fieldErrors.city}</p>}
|
||||
</div>
|
||||
<LocationFields
|
||||
city={state.city}
|
||||
state={state.state}
|
||||
fieldErrors={state.fieldErrors}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-state" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
State <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-state" aria-label=". CO"
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => {
|
||||
setState(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.state;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. CO"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.state
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.state && <p className="mt-1 text-xs text-red-500">{fieldErrors.state}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<LocationNameField
|
||||
location={state.location}
|
||||
fieldErrors={state.fieldErrors}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-location-name" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Location Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-location-name" aria-label=". Southwest Plaza Parking Lot"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => {
|
||||
setLocation(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.location;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. Southwest Plaza Parking Lot"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.location
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.location && <p className="mt-1 text-xs text-red-500">{fieldErrors.location}</p>}
|
||||
</div>
|
||||
<DateTimeFields
|
||||
date={state.date}
|
||||
time={state.time}
|
||||
fieldErrors={state.fieldErrors}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-date" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-date" aria-label="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.date;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.date
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.date && <p className="mt-1 text-xs text-red-500">{fieldErrors.date}</p>}
|
||||
</div>
|
||||
<BrandField
|
||||
brandId={state.brandId}
|
||||
fieldErrors={state.fieldErrors}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-time" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Time <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-time" aria-label=". 8:00 AM – 2:00 PM"
|
||||
type="text"
|
||||
value={time}
|
||||
onChange={(e) => {
|
||||
setTime(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.time;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. 8:00 AM – 2:00 PM"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.time
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.time && <p className="mt-1 text-xs text-red-500">{fieldErrors.time}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<ActiveField
|
||||
active={state.active}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-brand" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Brand <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select id="fld-brand" aria-label="Select"
|
||||
value={brandId}
|
||||
onChange={(e) => {
|
||||
setBrandId(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.brandId;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.brandId
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
>
|
||||
<option value="">Select brand...</option>
|
||||
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">Tuxedo Corn</option>
|
||||
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">Indian River Direct</option>
|
||||
</select>
|
||||
{fieldErrors.brandId && <p className="mt-1 text-xs text-red-500">{fieldErrors.brandId}</p>}
|
||||
</div>
|
||||
<AddressFields
|
||||
address={state.address}
|
||||
zip={state.zip}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-1-active" className="block text-xs font-semibold text-stone-700 mb-1.5">Active</label>
|
||||
<select id="fld-1-active" aria-label="Select"
|
||||
value={active}
|
||||
onChange={(e) => setActive(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="true">Yes — show on storefront</option>
|
||||
<option value="false">No — hide from storefront</option>
|
||||
</select>
|
||||
</div>
|
||||
<CutoffField
|
||||
cutoffTime={state.cutoffTime}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-2-street-address" className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
|
||||
<input id="fld-2-street-address" aria-label="123 Main St"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-3-zip-code" className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
|
||||
<input id="fld-3-zip-code" aria-label="80102"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80102"
|
||||
maxLength={10}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-cutoff" className="block text-xs font-semibold text-stone-700 mb-1.5">Order Cutoff</label>
|
||||
<p className="text-[10px] text-stone-500 mb-2">Customers must order before this time to be included at this stop.</p>
|
||||
<input id="fld-cutoff" aria-label="Datetime Local"
|
||||
type="datetime-local"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Stop"}
|
||||
</AdminButton>
|
||||
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="rounded-xl border border-[var(--admin-border)] px-6 py-3 font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
<FormActions loading={state.loading} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationFields({
|
||||
city,
|
||||
state,
|
||||
fieldErrors,
|
||||
dispatch,
|
||||
}: {
|
||||
city: string;
|
||||
state: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-city" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
City <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-city" aria-label=". Denver"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "city", value: e.target.value })}
|
||||
placeholder="e.g. Denver"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.city
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.city && <p className="mt-1 text-xs text-red-500">{fieldErrors.city}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-state" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
State <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-state" aria-label=". CO"
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "state", value: e.target.value })}
|
||||
placeholder="e.g. CO"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.state
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.state && <p className="mt-1 text-xs text-red-500">{fieldErrors.state}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationNameField({
|
||||
location,
|
||||
fieldErrors,
|
||||
dispatch,
|
||||
}: {
|
||||
location: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="fld-location-name" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Location Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-location-name" aria-label=". Southwest Plaza Parking Lot"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "location", value: e.target.value })}
|
||||
placeholder="e.g. Southwest Plaza Parking Lot"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.location
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.location && <p className="mt-1 text-xs text-red-500">{fieldErrors.location}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DateTimeFields({
|
||||
date,
|
||||
time,
|
||||
fieldErrors,
|
||||
dispatch,
|
||||
}: {
|
||||
date: string;
|
||||
time: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-date" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-date" aria-label="Date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "date", value: e.target.value })}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.date
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.date && <p className="mt-1 text-xs text-red-500">{fieldErrors.date}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-time" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Time <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input id="fld-time" aria-label=". 8:00 AM – 2:00 PM"
|
||||
type="text"
|
||||
value={time}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "time", value: e.target.value })}
|
||||
placeholder="e.g. 8:00 AM – 2:00 PM"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.time
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.time && <p className="mt-1 text-xs text-red-500">{fieldErrors.time}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandField({
|
||||
brandId,
|
||||
fieldErrors,
|
||||
dispatch,
|
||||
}: {
|
||||
brandId: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="fld-brand" className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Brand <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select id="fld-brand" aria-label="Select"
|
||||
value={brandId}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "brandId", value: e.target.value })}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.brandId
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
>
|
||||
<option value="">Select brand...</option>
|
||||
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">Tuxedo Corn</option>
|
||||
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">Indian River Direct</option>
|
||||
</select>
|
||||
{fieldErrors.brandId && <p className="mt-1 text-xs text-red-500">{fieldErrors.brandId}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveField({
|
||||
active,
|
||||
dispatch,
|
||||
}: {
|
||||
active: string;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="fld-1-active" className="block text-xs font-semibold text-stone-700 mb-1.5">Active</label>
|
||||
<select id="fld-1-active" aria-label="Select"
|
||||
value={active}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "active", value: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="true">Yes — show on storefront</option>
|
||||
<option value="false">No — hide from storefront</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressFields({
|
||||
address,
|
||||
zip,
|
||||
dispatch,
|
||||
}: {
|
||||
address: string;
|
||||
zip: string;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-2-street-address" className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
|
||||
<input id="fld-2-street-address" aria-label="123 Main St"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "address", value: e.target.value })}
|
||||
placeholder="123 Main St"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-3-zip-code" className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
|
||||
<input id="fld-3-zip-code" aria-label="80102"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "zip", value: e.target.value })}
|
||||
placeholder="80102"
|
||||
maxLength={10}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CutoffField({
|
||||
cutoffTime,
|
||||
dispatch,
|
||||
}: {
|
||||
cutoffTime: string;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="fld-cutoff" className="block text-xs font-semibold text-stone-700 mb-1.5">Order Cutoff</label>
|
||||
<p className="text-[10px] text-stone-500 mb-2">Customers must order before this time to be included at this stop.</p>
|
||||
<input id="fld-cutoff" aria-label="Datetime Local"
|
||||
type="datetime-local"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => dispatch({ type: "SET_FIELD", field: "cutoffTime", value: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormActions({ loading }: { loading: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Stop"}
|
||||
</AdminButton>
|
||||
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="rounded-xl border border-[var(--admin-border)] px-6 py-3 font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -210,7 +210,511 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const handleSave = useOrderSave({
|
||||
order,
|
||||
brandId,
|
||||
items,
|
||||
customer_name,
|
||||
customer_email,
|
||||
customer_phone,
|
||||
discount_amount,
|
||||
discount_reason,
|
||||
internal_notes,
|
||||
status,
|
||||
pickup_complete,
|
||||
subtotal,
|
||||
validateForm,
|
||||
setSaving,
|
||||
setError,
|
||||
showError,
|
||||
showSuccess,
|
||||
savedRef,
|
||||
router,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<OrderItemsSection
|
||||
visibleItems={visibleItems}
|
||||
onUpdateItem={updateItem}
|
||||
onRemoveItem={removeItem}
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
subtotal={subtotal}
|
||||
taxAmount={order.tax_amount ?? 0}
|
||||
discountAmount={discount_amount}
|
||||
discountReason={discount_reason}
|
||||
fieldErrors={fieldErrors}
|
||||
onDiscountAmountChange={(v) => setDiscount_amount(v)}
|
||||
onDiscountReasonChange={(v) => setDiscount_reason(v)}
|
||||
/>
|
||||
|
||||
<CustomerFieldsSection
|
||||
customerName={customer_name}
|
||||
customerEmail={customer_email}
|
||||
customerPhone={customer_phone}
|
||||
fieldErrors={fieldErrors}
|
||||
onCustomerNameChange={(v) => setCustomer_name(v)}
|
||||
onCustomerEmailChange={(v) => setCustomer_email(v)}
|
||||
onCustomerPhoneChange={(v) => setCustomer_phone(v)}
|
||||
onClearNameError={() =>
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.customer_name;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<StatusPickupSection
|
||||
status={status}
|
||||
pickupComplete={pickup_complete}
|
||||
onStatusChange={(s) => setStatus(s)}
|
||||
onPickupCompleteToggle={() => setPickup_complete((v) => !v)}
|
||||
/>
|
||||
|
||||
<InternalNotesSection
|
||||
internalNotes={internal_notes}
|
||||
onInternalNotesChange={(v) => setInternal_notes(v)}
|
||||
/>
|
||||
|
||||
<SaveButton saving={saving} onSave={handleSave} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ error }: { error: string }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-4 text-sm flex items-start gap-3"
|
||||
style={{
|
||||
borderColor: "var(--admin-danger-soft)",
|
||||
backgroundColor: "var(--admin-danger-soft)",
|
||||
color: "var(--admin-danger)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
style={{ color: "var(--admin-danger)" }}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderItemsSection({
|
||||
visibleItems,
|
||||
onUpdateItem,
|
||||
onRemoveItem,
|
||||
}: {
|
||||
visibleItems: EditableItem[];
|
||||
onUpdateItem: (id: string, field: "quantity" | "price", value: number) => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="ha-field-label mb-3">
|
||||
Order Items ({visibleItems.length})
|
||||
</p>
|
||||
{visibleItems.length === 0 ? (
|
||||
<p
|
||||
className="rounded-xl border border-dashed p-4 text-center text-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border-strong)",
|
||||
color: "var(--admin-text-muted)",
|
||||
}}
|
||||
>
|
||||
No items
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl border p-4"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p
|
||||
className="font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{item.productName}
|
||||
</p>
|
||||
<button type="button"
|
||||
onClick={() => onRemoveItem(item.id)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
|
||||
style={{ color: "var(--admin-danger)" }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="fld-1-qty" className="ha-field-label mb-1">Qty</label>
|
||||
<input id="fld-1-qty" aria-label="Number"
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
onUpdateItem(item.id, "quantity", Number(e.target.value))
|
||||
}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-3-price" className="ha-field-label mb-1">Price</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
$
|
||||
</span>
|
||||
<input id="fld-3-price" aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={item.price}
|
||||
onChange={(e) =>
|
||||
onUpdateItem(item.id, "price", Number(e.target.value))
|
||||
}
|
||||
className="ha-field-input ha-field-input-mono pl-8 pr-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="mt-2 text-right text-sm font-semibold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{formatCurrency(Number(item.price) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingSection({
|
||||
subtotal,
|
||||
taxAmount,
|
||||
discountAmount,
|
||||
discountReason,
|
||||
fieldErrors,
|
||||
onDiscountAmountChange,
|
||||
onDiscountReasonChange,
|
||||
}: {
|
||||
subtotal: number;
|
||||
taxAmount: number;
|
||||
discountAmount: number;
|
||||
discountReason: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
onDiscountAmountChange: (v: number) => void;
|
||||
onDiscountReasonChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AdminInput label="Subtotal (auto-calculated)">
|
||||
<input aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={subtotal}
|
||||
readOnly
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-4-discount-amount" className="ha-field-label mb-1.5">Discount Amount</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
$
|
||||
</span>
|
||||
<input id="fld-4-discount-amount" aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={discountAmount}
|
||||
onChange={(e) => onDiscountAmountChange(Number(e.target.value))}
|
||||
className="ha-field-input ha-field-input-mono pl-8 pr-3"
|
||||
style={
|
||||
fieldErrors.discount_amount
|
||||
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.discount_amount && (
|
||||
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
|
||||
{fieldErrors.discount_amount}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-2-discount-reason" className="ha-field-label mb-1.5">Discount Reason</label>
|
||||
<input id="fld-2-discount-reason" aria-label="Optional"
|
||||
type="text"
|
||||
value={discountReason}
|
||||
onChange={(e) => onDiscountReasonChange(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="ha-field-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-secondary)" }}
|
||||
>
|
||||
Total
|
||||
</span>
|
||||
<span
|
||||
className="text-xl font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{formatCurrency(subtotal + taxAmount - discountAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerFieldsSection({
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
fieldErrors,
|
||||
onCustomerNameChange,
|
||||
onCustomerEmailChange,
|
||||
onCustomerPhoneChange,
|
||||
onClearNameError,
|
||||
}: {
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
onCustomerNameChange: (v: string) => void;
|
||||
onCustomerEmailChange: (v: string) => void;
|
||||
onCustomerPhoneChange: (v: string) => void;
|
||||
onClearNameError: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="fld-5-name" className="ha-field-label mb-1.5">
|
||||
<span>Name</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input id="fld-5-name" aria-label="Text"
|
||||
type="text"
|
||||
value={customerName}
|
||||
onChange={(e) => {
|
||||
onCustomerNameChange(e.target.value);
|
||||
onClearNameError();
|
||||
}}
|
||||
className="ha-field-input"
|
||||
style={
|
||||
fieldErrors.customer_name
|
||||
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{fieldErrors.customer_name && (
|
||||
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
|
||||
{fieldErrors.customer_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AdminInput label="Email">
|
||||
<AdminTextInput
|
||||
type="email"
|
||||
value={customerEmail}
|
||||
onChange={(e) => onCustomerEmailChange(e.target.value)}
|
||||
/>
|
||||
</AdminInput>
|
||||
<AdminInput label="Phone">
|
||||
<AdminTextInput
|
||||
type="tel"
|
||||
value={customerPhone}
|
||||
onChange={(e) => onCustomerPhoneChange(e.target.value)}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPickupSection({
|
||||
status,
|
||||
pickupComplete,
|
||||
onStatusChange,
|
||||
onPickupCompleteToggle,
|
||||
}: {
|
||||
status: string;
|
||||
pickupComplete: boolean;
|
||||
onStatusChange: (s: string) => void;
|
||||
onPickupCompleteToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="ha-field-label mb-2">Status</div>
|
||||
<div className="ha-segment">
|
||||
{["pending", "confirmed", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onStatusChange(s)}
|
||||
className={`ha-segment-btn capitalize ${
|
||||
status === s ? "ha-segment-btn--active" : ""
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="ha-field-label mb-2">Pickup</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickupCompleteToggle}
|
||||
className="ha-segment-btn w-full justify-center"
|
||||
style={
|
||||
pickupComplete
|
||||
? {
|
||||
backgroundColor: "var(--admin-primary-soft)",
|
||||
color: "var(--admin-primary)",
|
||||
border: "1px solid var(--admin-primary)",
|
||||
}
|
||||
: {
|
||||
backgroundColor: "var(--admin-warning-soft)",
|
||||
color: "var(--admin-warning)",
|
||||
border: "1px solid var(--admin-warning)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{pickupComplete ? "✓ Picked Up" : "○ Not Picked Up"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InternalNotesSection({
|
||||
internalNotes,
|
||||
onInternalNotesChange,
|
||||
}: {
|
||||
internalNotes: string;
|
||||
onInternalNotesChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<AdminInput label="Internal Notes">
|
||||
<AdminTextarea
|
||||
value={internalNotes}
|
||||
onChange={(e) => onInternalNotesChange(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Private notes for staff..."
|
||||
/>
|
||||
</AdminInput>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton({ saving, onSave }: { saving: boolean; onSave: () => void }) {
|
||||
return (
|
||||
<AdminButton
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
size="lg"
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</AdminButton>
|
||||
);
|
||||
}
|
||||
|
||||
type UseOrderSaveArgs = {
|
||||
order: Order;
|
||||
brandId: string | null;
|
||||
items: EditableItem[];
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
customer_phone: string;
|
||||
discount_amount: number;
|
||||
discount_reason: string;
|
||||
internal_notes: string;
|
||||
status: string;
|
||||
pickup_complete: boolean;
|
||||
subtotal: number;
|
||||
validateForm: () => boolean;
|
||||
setSaving: (v: boolean) => void;
|
||||
setError: (v: string | null) => void;
|
||||
showError: (title: string, message: string) => void;
|
||||
showSuccess: (title: string, message: string) => void;
|
||||
savedRef: React.MutableRefObject<boolean>;
|
||||
router: ReturnType<typeof useRouter>;
|
||||
};
|
||||
|
||||
function useOrderSave(args: UseOrderSaveArgs): () => Promise<void> {
|
||||
const {
|
||||
order,
|
||||
brandId,
|
||||
items,
|
||||
customer_name,
|
||||
customer_email,
|
||||
customer_phone,
|
||||
discount_amount,
|
||||
discount_reason,
|
||||
internal_notes,
|
||||
status,
|
||||
pickup_complete,
|
||||
subtotal,
|
||||
validateForm,
|
||||
setSaving,
|
||||
setError,
|
||||
showError,
|
||||
showSuccess,
|
||||
savedRef,
|
||||
router,
|
||||
} = args;
|
||||
|
||||
return async function handleSave() {
|
||||
if (!validateForm()) {
|
||||
showError("Validation failed", "Please fix the errors below");
|
||||
return;
|
||||
@@ -300,321 +804,9 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
savedRef.current = true;
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
showError("Network error", "Please check your connection and try again");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div
|
||||
className="rounded-xl border p-4 text-sm flex items-start gap-3"
|
||||
style={{
|
||||
borderColor: "var(--admin-danger-soft)",
|
||||
backgroundColor: "var(--admin-danger-soft)",
|
||||
color: "var(--admin-danger)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
style={{ color: "var(--admin-danger)" }}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div>
|
||||
<p className="ha-field-label mb-3">
|
||||
Order Items ({visibleItems.length})
|
||||
</p>
|
||||
{visibleItems.length === 0 ? (
|
||||
<p
|
||||
className="rounded-xl border border-dashed p-4 text-center text-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border-strong)",
|
||||
color: "var(--admin-text-muted)",
|
||||
}}
|
||||
>
|
||||
No items
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl border p-4"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p
|
||||
className="font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{item.productName}
|
||||
</p>
|
||||
<button type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
|
||||
style={{ color: "var(--admin-danger)" }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="fld-1-qty" className="ha-field-label mb-1">Qty</label>
|
||||
<input id="fld-1-qty" aria-label="Number"
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "quantity", Number(e.target.value))
|
||||
}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-3-price" className="ha-field-label mb-1">Price</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
$
|
||||
</span>
|
||||
<input id="fld-3-price" aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={item.price}
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "price", Number(e.target.value))
|
||||
}
|
||||
className="ha-field-input ha-field-input-mono pl-8 pr-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="mt-2 text-right text-sm font-semibold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{formatCurrency(Number(item.price) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="space-y-4">
|
||||
<AdminInput label="Subtotal (auto-calculated)">
|
||||
<input aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={subtotal}
|
||||
readOnly
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-4-discount-amount" className="ha-field-label mb-1.5">Discount Amount</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
$
|
||||
</span>
|
||||
<input id="fld-4-discount-amount" aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={discount_amount}
|
||||
onChange={(e) => setDiscount_amount(Number(e.target.value))}
|
||||
className="ha-field-input ha-field-input-mono pl-8 pr-3"
|
||||
style={
|
||||
fieldErrors.discount_amount
|
||||
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.discount_amount && (
|
||||
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
|
||||
{fieldErrors.discount_amount}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-2-discount-reason" className="ha-field-label mb-1.5">Discount Reason</label>
|
||||
<input id="fld-2-discount-reason" aria-label="Optional"
|
||||
type="text"
|
||||
value={discount_reason}
|
||||
onChange={(e) => setDiscount_reason(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="ha-field-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-secondary)" }}
|
||||
>
|
||||
Total
|
||||
</span>
|
||||
<span
|
||||
className="text-xl font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="fld-5-name" className="ha-field-label mb-1.5">
|
||||
<span>Name</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input id="fld-5-name" aria-label="Text"
|
||||
type="text"
|
||||
value={customer_name}
|
||||
onChange={(e) => {
|
||||
setCustomer_name(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.customer_name;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="ha-field-input"
|
||||
style={
|
||||
fieldErrors.customer_name
|
||||
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{fieldErrors.customer_name && (
|
||||
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
|
||||
{fieldErrors.customer_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AdminInput label="Email">
|
||||
<AdminTextInput
|
||||
type="email"
|
||||
value={customer_email}
|
||||
onChange={(e) => setCustomer_email(e.target.value)}
|
||||
/>
|
||||
</AdminInput>
|
||||
<AdminInput label="Phone">
|
||||
<AdminTextInput
|
||||
type="tel"
|
||||
value={customer_phone}
|
||||
onChange={(e) => setCustomer_phone(e.target.value)}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & pickup */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="ha-field-label mb-2">Status</div>
|
||||
<div className="ha-segment">
|
||||
{["pending", "confirmed", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setStatus(s)}
|
||||
className={`ha-segment-btn capitalize ${
|
||||
status === s ? "ha-segment-btn--active" : ""
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="ha-field-label mb-2">Pickup</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickup_complete((v) => !v)}
|
||||
className="ha-segment-btn w-full justify-center"
|
||||
style={
|
||||
pickup_complete
|
||||
? {
|
||||
backgroundColor: "var(--admin-primary-soft)",
|
||||
color: "var(--admin-primary)",
|
||||
border: "1px solid var(--admin-primary)",
|
||||
}
|
||||
: {
|
||||
backgroundColor: "var(--admin-warning-soft)",
|
||||
color: "var(--admin-warning)",
|
||||
border: "1px solid var(--admin-warning)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{pickup_complete ? "✓ Picked Up" : "○ Not Picked Up"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes */}
|
||||
<AdminInput label="Internal Notes">
|
||||
<AdminTextarea
|
||||
value={internal_notes}
|
||||
onChange={(e) => setInternal_notes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Private notes for staff..."
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
size="lg"
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { updateOrder } from "@/actions/orders/update-order";
|
||||
@@ -37,27 +37,351 @@ function formatCurrency(amount: number) {
|
||||
return currencyFormatter.format(amount);
|
||||
}
|
||||
|
||||
export default function OrderPaymentSection({
|
||||
orderId,
|
||||
brandId,
|
||||
orderTotal,
|
||||
payment_processor,
|
||||
payment_status,
|
||||
payment_transaction_id,
|
||||
existingRefunds,
|
||||
}: OrderPaymentSectionProps) {
|
||||
// --- Reducer state -------------------------------------------------------
|
||||
|
||||
type State = {
|
||||
processor: string;
|
||||
status: string;
|
||||
transactionId: string;
|
||||
refundAmount: string;
|
||||
refundReason: string;
|
||||
saving: boolean;
|
||||
refunding: boolean;
|
||||
error: string | null;
|
||||
saved: boolean;
|
||||
refundSaved: boolean;
|
||||
refundError: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_PROCESSOR"; value: string }
|
||||
| { type: "SET_STATUS"; value: string }
|
||||
| { type: "SET_TRANSACTION_ID"; value: string }
|
||||
| { type: "SET_REFUND_AMOUNT"; value: string }
|
||||
| { type: "SET_REFUND_REASON"; value: string }
|
||||
| { type: "SET_SAVING"; value: boolean }
|
||||
| { type: "SET_REFUNDING"; value: boolean }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SET_SAVED"; value: boolean }
|
||||
| { type: "SET_REFUND_SAVED"; value: boolean }
|
||||
| { type: "SET_REFUND_ERROR"; value: string | null }
|
||||
| { type: "RESET_REFUND_FIELDS" };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_PROCESSOR":
|
||||
return { ...state, processor: action.value };
|
||||
case "SET_STATUS":
|
||||
return { ...state, status: action.value };
|
||||
case "SET_TRANSACTION_ID":
|
||||
return { ...state, transactionId: action.value };
|
||||
case "SET_REFUND_AMOUNT":
|
||||
return { ...state, refundAmount: action.value };
|
||||
case "SET_REFUND_REASON":
|
||||
return { ...state, refundReason: action.value };
|
||||
case "SET_SAVING":
|
||||
return { ...state, saving: action.value };
|
||||
case "SET_REFUNDING":
|
||||
return { ...state, refunding: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SET_SAVED":
|
||||
return { ...state, saved: action.value };
|
||||
case "SET_REFUND_SAVED":
|
||||
return { ...state, refundSaved: action.value };
|
||||
case "SET_REFUND_ERROR":
|
||||
return { ...state, refundError: action.value };
|
||||
case "RESET_REFUND_FIELDS":
|
||||
return { ...state, refundAmount: "", refundReason: "", refundSaved: true };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function createInitialState(props: OrderPaymentSectionProps): State {
|
||||
return {
|
||||
processor: props.payment_processor ?? "",
|
||||
status: props.payment_status ?? "manual",
|
||||
transactionId: props.payment_transaction_id ?? "",
|
||||
refundAmount: "",
|
||||
refundReason: "",
|
||||
saving: false,
|
||||
refunding: false,
|
||||
error: null,
|
||||
saved: false,
|
||||
refundSaved: false,
|
||||
refundError: null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Subcomponents -------------------------------------------------------
|
||||
|
||||
type BalanceSummaryProps = {
|
||||
orderTotal: number;
|
||||
totalRefunded: number;
|
||||
remainingBalance: number;
|
||||
};
|
||||
|
||||
function BalanceSummary({ orderTotal, totalRefunded, remainingBalance }: BalanceSummaryProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-stone-500">Order Total</p>
|
||||
<p className="text-lg font-bold text-stone-900">{formatCurrency(orderTotal)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-stone-500">Refunded</p>
|
||||
<p className="text-lg font-bold text-green-600">{formatCurrency(totalRefunded)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-stone-500">Balance Due</p>
|
||||
<p className="text-lg font-bold text-[var(--admin-accent)]">{formatCurrency(remainingBalance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PaymentDetailsFormProps = {
|
||||
processor: string;
|
||||
status: string;
|
||||
transactionId: string;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
saved: boolean;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onChangeProcessor: (value: string) => void;
|
||||
onChangeStatus: (value: string) => void;
|
||||
onChangeTransactionId: (value: string) => void;
|
||||
};
|
||||
|
||||
function PaymentDetailsForm({
|
||||
processor,
|
||||
status,
|
||||
transactionId,
|
||||
saving,
|
||||
error,
|
||||
saved,
|
||||
onSubmit,
|
||||
onChangeProcessor,
|
||||
onChangeStatus,
|
||||
onChangeTransactionId,
|
||||
}: PaymentDetailsFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700 flex items-center gap-3">
|
||||
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Payment details saved
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-1-processor" className="mb-1 block text-xs font-semibold text-stone-500">Processor</label>
|
||||
<select id="fld-1-processor" aria-label="Select"
|
||||
value={processor}
|
||||
onChange={(e) => onChangeProcessor(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-2-payment-status" className="mb-1 block text-xs font-semibold text-stone-500">Payment Status</label>
|
||||
<select id="fld-2-payment-status" aria-label="Select"
|
||||
value={status}
|
||||
onChange={(e) => onChangeStatus(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-3-transaction-id" className="mb-1 block text-xs font-semibold text-stone-500">Transaction ID</label>
|
||||
<input id="fld-3-transaction-id" aria-label="External Payment Reference"
|
||||
type="text"
|
||||
value={transactionId}
|
||||
onChange={(e) => onChangeTransactionId(e.target.value)}
|
||||
placeholder="External payment reference"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Payment Details"}
|
||||
</AdminButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type RefundHistoryProps = {
|
||||
refunds: Refund[];
|
||||
};
|
||||
|
||||
function RefundHistory({ refunds }: RefundHistoryProps) {
|
||||
if (refunds.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">
|
||||
Refund History ({refunds.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{refunds.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between rounded-xl border border-stone-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">
|
||||
{formatCurrency(Number(r.amount))}
|
||||
</p>
|
||||
{r.reason && (
|
||||
<p className="text-xs text-stone-500">{r.reason}</p>
|
||||
)}
|
||||
<p className="text-xs text-stone-400">
|
||||
{formatDate(r.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${
|
||||
r.status === "completed"
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||||
}`}
|
||||
>
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RecordRefundFormProps = {
|
||||
refundAmount: string;
|
||||
refundReason: string;
|
||||
refundError: string | null;
|
||||
refundSaved: boolean;
|
||||
refunding: boolean;
|
||||
remainingBalance: number;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onChangeRefundAmount: (value: string) => void;
|
||||
onChangeRefundReason: (value: string) => void;
|
||||
};
|
||||
|
||||
function RecordRefundForm({
|
||||
refundAmount,
|
||||
refundReason,
|
||||
refundError,
|
||||
refundSaved,
|
||||
refunding,
|
||||
remainingBalance,
|
||||
onSubmit,
|
||||
onChangeRefundAmount,
|
||||
onChangeRefundReason,
|
||||
}: RecordRefundFormProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">Record a Refund</p>
|
||||
{refundSaved && (
|
||||
<div className="mb-3 rounded-xl bg-green-50 border border-green-200 p-3 text-sm text-green-700 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Refund recorded successfully
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500" htmlFor="fld-amount">Amount</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input id="fld-amount" aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={remainingBalance}
|
||||
value={refundAmount}
|
||||
onChange={(e) => onChangeRefundAmount(e.target.value)}
|
||||
placeholder={`Max ${formatCurrency(remainingBalance)}`}
|
||||
className={`w-full rounded-xl border bg-white pl-8 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
refundError
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-stone-200 focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{refundError && (
|
||||
<p className="mt-1 text-xs text-red-500">{refundError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-4-reason" className="mb-1 block text-xs font-semibold text-stone-500">Reason</label>
|
||||
<input id="fld-4-reason" aria-label="Customer Request, Defective Product, Etc."
|
||||
type="text"
|
||||
value={refundReason}
|
||||
onChange={(e) => onChangeRefundReason(e.target.value)}
|
||||
placeholder="Customer request, defective product, etc."
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
|
||||
isLoading={refunding}
|
||||
variant="danger"
|
||||
fullWidth
|
||||
>
|
||||
{refunding ? "Processing..." : `Record Refund (Max ${formatCurrency(remainingBalance)})`}
|
||||
</AdminButton>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main component ------------------------------------------------------
|
||||
|
||||
export default function OrderPaymentSection(props: OrderPaymentSectionProps) {
|
||||
const {
|
||||
orderId,
|
||||
brandId,
|
||||
orderTotal,
|
||||
existingRefunds,
|
||||
} = props;
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [processor, setProcessor] = useState(payment_processor ?? "");
|
||||
const [status, setStatus] = useState(payment_status ?? "manual");
|
||||
const [transactionId, setTransactionId] = useState(payment_transaction_id ?? "");
|
||||
const [refundAmount, setRefundAmount] = useState("");
|
||||
const [refundReason, setRefundReason] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [refunding, setRefunding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [refundSaved, setRefundSaved] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, createInitialState(props));
|
||||
|
||||
const totalRefunded = existingRefunds
|
||||
.filter((r) => r.status === "completed")
|
||||
@@ -65,264 +389,109 @@ export default function OrderPaymentSection({
|
||||
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
|
||||
|
||||
// Validation for refund
|
||||
const [refundError, setRefundError] = useState<string | null>(null);
|
||||
|
||||
function validateRefund(): boolean {
|
||||
const amount = Number(refundAmount);
|
||||
const amount = Number(state.refundAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
setRefundError("Please enter a valid refund amount");
|
||||
dispatch({ type: "SET_REFUND_ERROR", value: "Please enter a valid refund amount" });
|
||||
return false;
|
||||
}
|
||||
if (amount > remainingBalance) {
|
||||
setRefundError(`Amount cannot exceed remaining balance of ${formatCurrency(remainingBalance)}`);
|
||||
dispatch({
|
||||
type: "SET_REFUND_ERROR",
|
||||
value: `Amount cannot exceed remaining balance of ${formatCurrency(remainingBalance)}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
setRefundError(null);
|
||||
dispatch({ type: "SET_REFUND_ERROR", value: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSavePayment(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
dispatch({ type: "SET_SAVING", value: true });
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
dispatch({ type: "SET_SAVED", value: false });
|
||||
|
||||
const result = await updateOrder(orderId, brandId, {
|
||||
payment_processor: processor || null,
|
||||
payment_status: status,
|
||||
payment_transaction_id: transactionId || null,
|
||||
payment_processor: state.processor || null,
|
||||
payment_status: state.status,
|
||||
payment_transaction_id: state.transactionId || null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
showError("Failed to save", result.error ?? "Please try again");
|
||||
setError(result.error ?? "Failed to save");
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
|
||||
} else {
|
||||
showSuccess("Payment saved", "Payment details have been updated");
|
||||
setSaved(true);
|
||||
dispatch({ type: "SET_SAVED", value: true });
|
||||
}
|
||||
setSaving(false);
|
||||
dispatch({ type: "SET_SAVING", value: false });
|
||||
}
|
||||
|
||||
async function handleRefund(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validateRefund()) return;
|
||||
|
||||
const amount = Number(refundAmount);
|
||||
const amount = Number(state.refundAmount);
|
||||
|
||||
setRefunding(true);
|
||||
setError(null);
|
||||
setRefundSaved(false);
|
||||
dispatch({ type: "SET_REFUNDING", value: true });
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
dispatch({ type: "SET_REFUND_SAVED", value: false });
|
||||
|
||||
const result = await createRefund(orderId, brandId, {
|
||||
amount,
|
||||
reason: refundReason || null,
|
||||
reason: state.refundReason || null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
showError("Failed to record refund", result.error ?? "Please try again");
|
||||
setError(result.error ?? "Failed to record refund");
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to record refund" });
|
||||
} else {
|
||||
showSuccess("Refund recorded", `${formatCurrency(amount)} has been refunded`);
|
||||
setRefundAmount("");
|
||||
setRefundReason("");
|
||||
setRefundSaved(true);
|
||||
dispatch({ type: "RESET_REFUND_FIELDS" });
|
||||
router.refresh();
|
||||
}
|
||||
setRefunding(false);
|
||||
dispatch({ type: "SET_REFUNDING", value: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Balance summary */}
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-stone-500">Order Total</p>
|
||||
<p className="text-lg font-bold text-stone-900">{formatCurrency(orderTotal)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-stone-500">Refunded</p>
|
||||
<p className="text-lg font-bold text-green-600">{formatCurrency(totalRefunded)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-stone-500">Balance Due</p>
|
||||
<p className="text-lg font-bold text-[var(--admin-accent)]">{formatCurrency(remainingBalance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BalanceSummary
|
||||
orderTotal={orderTotal}
|
||||
totalRefunded={totalRefunded}
|
||||
remainingBalance={remainingBalance}
|
||||
/>
|
||||
|
||||
{/* Payment details */}
|
||||
<form onSubmit={handleSavePayment} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<PaymentDetailsForm
|
||||
processor={state.processor}
|
||||
status={state.status}
|
||||
transactionId={state.transactionId}
|
||||
saving={state.saving}
|
||||
error={state.error}
|
||||
saved={state.saved}
|
||||
onSubmit={handleSavePayment}
|
||||
onChangeProcessor={(v) => dispatch({ type: "SET_PROCESSOR", value: v })}
|
||||
onChangeStatus={(v) => dispatch({ type: "SET_STATUS", value: v })}
|
||||
onChangeTransactionId={(v) => dispatch({ type: "SET_TRANSACTION_ID", value: v })}
|
||||
/>
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700 flex items-center gap-3">
|
||||
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Payment details saved
|
||||
</div>
|
||||
)}
|
||||
<RefundHistory refunds={existingRefunds} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-1-processor" className="mb-1 block text-xs font-semibold text-stone-500">Processor</label>
|
||||
<select id="fld-1-processor" aria-label="Select"
|
||||
value={processor}
|
||||
onChange={(e) => setProcessor(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-2-payment-status" className="mb-1 block text-xs font-semibold text-stone-500">Payment Status</label>
|
||||
<select id="fld-2-payment-status" aria-label="Select"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-3-transaction-id" className="mb-1 block text-xs font-semibold text-stone-500">Transaction ID</label>
|
||||
<input id="fld-3-transaction-id" aria-label="External Payment Reference"
|
||||
type="text"
|
||||
value={transactionId}
|
||||
onChange={(e) => setTransactionId(e.target.value)}
|
||||
placeholder="External payment reference"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Payment Details"}
|
||||
</AdminButton>
|
||||
</form>
|
||||
|
||||
{/* Refund history */}
|
||||
{existingRefunds.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">
|
||||
Refund History ({existingRefunds.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{existingRefunds.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between rounded-xl border border-stone-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">
|
||||
{formatCurrency(Number(r.amount))}
|
||||
</p>
|
||||
{r.reason && (
|
||||
<p className="text-xs text-stone-500">{r.reason}</p>
|
||||
)}
|
||||
<p className="text-xs text-stone-400">
|
||||
{formatDate(r.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${
|
||||
r.status === "completed"
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||||
}`}
|
||||
>
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record a refund */}
|
||||
{remainingBalance > 0 && (
|
||||
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">Record a Refund</p>
|
||||
{refundSaved && (
|
||||
<div className="mb-3 rounded-xl bg-green-50 border border-green-200 p-3 text-sm text-green-700 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Refund recorded successfully
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleRefund} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500" htmlFor="fld-amount">Amount</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input id="fld-amount" aria-label="Number"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={remainingBalance}
|
||||
value={refundAmount}
|
||||
onChange={(e) => {
|
||||
setRefundAmount(e.target.value);
|
||||
setRefundError(null);
|
||||
}}
|
||||
placeholder={`Max ${formatCurrency(remainingBalance)}`}
|
||||
className={`w-full rounded-xl border bg-white pl-8 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
refundError
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-stone-200 focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{refundError && (
|
||||
<p className="mt-1 text-xs text-red-500">{refundError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-4-reason" className="mb-1 block text-xs font-semibold text-stone-500">Reason</label>
|
||||
<input id="fld-4-reason" aria-label="Customer Request, Defective Product, Etc."
|
||||
type="text"
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
placeholder="Customer request, defective product, etc."
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
|
||||
isLoading={refunding}
|
||||
variant="danger"
|
||||
fullWidth
|
||||
>
|
||||
{refunding ? "Processing..." : `Record Refund (Max ${formatCurrency(remainingBalance)})`}
|
||||
</AdminButton>
|
||||
</form>
|
||||
</div>
|
||||
<RecordRefundForm
|
||||
refundAmount={state.refundAmount}
|
||||
refundReason={state.refundReason}
|
||||
refundError={state.refundError}
|
||||
refundSaved={state.refundSaved}
|
||||
refunding={state.refunding}
|
||||
remainingBalance={remainingBalance}
|
||||
onSubmit={handleRefund}
|
||||
onChangeRefundAmount={(v) => {
|
||||
dispatch({ type: "SET_REFUND_AMOUNT", value: v });
|
||||
dispatch({ type: "SET_REFUND_ERROR", value: null });
|
||||
}}
|
||||
onChangeRefundReason={(v) => dispatch({ type: "SET_REFUND_REASON", value: v })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useRef } from "react";
|
||||
import NextImage from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
|
||||
import { updateProduct } from "@/actions/products/update-product";
|
||||
@@ -57,102 +57,104 @@ type ProductEditFormProps = {
|
||||
brands: Brand[];
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
type?: string;
|
||||
active?: boolean;
|
||||
brand_id?: string;
|
||||
image_url?: string;
|
||||
is_taxable?: boolean;
|
||||
pickup_type?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
draft: Draft;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
saved: boolean;
|
||||
dragOver: boolean;
|
||||
uploading: boolean;
|
||||
uploadError: string | null;
|
||||
imagePreview: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_DRAFT_FIELD"; field: "name"; value: string }
|
||||
| { type: "SET_DRAFT_FIELD"; field: "description"; value: string }
|
||||
| { type: "SET_DRAFT_FIELD"; field: "type"; value: string }
|
||||
| { type: "SET_DRAFT_FIELD"; field: "brand_id"; value: string }
|
||||
| { type: "SET_DRAFT_FIELD"; field: "image_url"; value: string }
|
||||
| { type: "SET_DRAFT_FIELD"; field: "pickup_type"; value: string }
|
||||
| { type: "SET_DRAFT_FIELD"; field: "price"; value: number }
|
||||
| { type: "SET_DRAFT_FIELD"; field: "active"; value: boolean }
|
||||
| { type: "SET_DRAFT_FIELD"; field: "is_taxable"; value: boolean }
|
||||
| { type: "SET_SAVING"; value: boolean }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SET_SAVED"; value: boolean }
|
||||
| { type: "SET_DRAG_OVER"; value: boolean }
|
||||
| { type: "SET_UPLOADING"; value: boolean }
|
||||
| { type: "SET_UPLOAD_ERROR"; value: string | null }
|
||||
| { type: "SET_IMAGE_PREVIEW"; value: string | null };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_DRAFT_FIELD":
|
||||
return { ...state, draft: { ...state.draft, [action.field]: action.value } };
|
||||
case "SET_SAVING":
|
||||
return { ...state, saving: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SET_SAVED":
|
||||
return { ...state, saved: action.value };
|
||||
case "SET_DRAG_OVER":
|
||||
return { ...state, dragOver: action.value };
|
||||
case "SET_UPLOADING":
|
||||
return { ...state, uploading: action.value };
|
||||
case "SET_UPLOAD_ERROR":
|
||||
return { ...state, uploadError: action.value };
|
||||
case "SET_IMAGE_PREVIEW":
|
||||
return { ...state, imagePreview: action.value };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProductEditForm({ product, brands }: ProductEditFormProps) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, undefined, () => ({
|
||||
draft: {},
|
||||
saving: false,
|
||||
error: null,
|
||||
saved: false,
|
||||
dragOver: false,
|
||||
uploading: false,
|
||||
uploadError: null,
|
||||
imagePreview: product.image_url ?? null,
|
||||
}));
|
||||
|
||||
// Holds user edits only; missing keys fall back to the current prop so we
|
||||
// never copy the prop into useState. When the prop changes, the fallback
|
||||
// value tracks the new prop automatically.
|
||||
const [draft, setDraft] = useState<{
|
||||
name?: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
type?: string;
|
||||
active?: boolean;
|
||||
brand_id?: string;
|
||||
image_url?: string;
|
||||
is_taxable?: boolean;
|
||||
pickup_type?: string;
|
||||
}>({});
|
||||
|
||||
const name = draft.name ?? product.name;
|
||||
const description = draft.description ?? product.description;
|
||||
const price = draft.price ?? product.price;
|
||||
const type = draft.type ?? product.type;
|
||||
const active = draft.active ?? product.active;
|
||||
const brand_id = draft.brand_id ?? product.brand_id;
|
||||
const image_url = draft.image_url ?? product.image_url ?? "";
|
||||
const is_taxable = draft.is_taxable ?? product.is_taxable ?? true;
|
||||
const pickup_type = draft.pickup_type ?? product.pickup_type ?? "scheduled_stop";
|
||||
|
||||
const setName = (v: string | ((prev: string) => string)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
name: typeof v === "function" ? v(d.name ?? product.name) : v,
|
||||
}));
|
||||
const setDescription = (v: string | ((prev: string) => string)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
description: typeof v === "function" ? v(d.description ?? product.description) : v,
|
||||
}));
|
||||
const setPrice = (v: number | ((prev: number) => number)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
price: typeof v === "function" ? v(d.price ?? product.price) : v,
|
||||
}));
|
||||
const setType = (v: string | ((prev: string) => string)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
type: typeof v === "function" ? v(d.type ?? product.type) : v,
|
||||
}));
|
||||
const setActive = (v: boolean | ((prev: boolean) => boolean)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
active: typeof v === "function" ? v(d.active ?? product.active) : v,
|
||||
}));
|
||||
const setBrand_id = (v: string | ((prev: string) => string)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
brand_id: typeof v === "function" ? v(d.brand_id ?? product.brand_id) : v,
|
||||
}));
|
||||
const setImage_url = (v: string | ((prev: string) => string)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
image_url:
|
||||
typeof v === "function" ? v(d.image_url ?? product.image_url ?? "") : v,
|
||||
}));
|
||||
const setIs_taxable = (v: boolean | ((prev: boolean) => boolean)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
is_taxable:
|
||||
typeof v === "function" ? v(d.is_taxable ?? product.is_taxable ?? true) : v,
|
||||
}));
|
||||
const setPickup_type = (v: string | ((prev: string) => string)) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
pickup_type:
|
||||
typeof v === "function"
|
||||
? v(d.pickup_type ?? product.pickup_type ?? "scheduled_stop")
|
||||
: v,
|
||||
}));
|
||||
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(product.image_url ?? null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Derived effective values from draft overrides + product prop
|
||||
const name = state.draft.name ?? product.name;
|
||||
const description = state.draft.description ?? product.description;
|
||||
const price = state.draft.price ?? product.price;
|
||||
const type = state.draft.type ?? product.type;
|
||||
const active = state.draft.active ?? product.active;
|
||||
const brand_id = state.draft.brand_id ?? product.brand_id;
|
||||
const image_url = state.draft.image_url ?? product.image_url ?? "";
|
||||
const is_taxable = state.draft.is_taxable ?? product.is_taxable ?? true;
|
||||
const pickup_type = state.draft.pickup_type ?? product.pickup_type ?? "scheduled_stop";
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
||||
dispatch({ type: "SET_UPLOAD_ERROR", value: "Only PNG, JPEG, and WebP images are allowed." });
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Image must be under 5MB.");
|
||||
dispatch({ type: "SET_UPLOAD_ERROR", value: "Image must be under 5MB." });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,35 +162,35 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
dispatch({ type: "SET_UPLOAD_ERROR", value: null });
|
||||
dispatch({ type: "SET_UPLOADING", value: true });
|
||||
const result = await uploadProductImage(product.id, resizedFile);
|
||||
setUploading(false);
|
||||
dispatch({ type: "SET_UPLOADING", value: false });
|
||||
|
||||
if (result.success) {
|
||||
setImage_url(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
dispatch({ type: "SET_DRAFT_FIELD", field: "image_url", value: result.imageUrl });
|
||||
dispatch({ type: "SET_IMAGE_PREVIEW", value: result.imageUrl });
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
dispatch({ type: "SET_UPLOAD_ERROR", value: result.error ?? "Upload failed." });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveImage() {
|
||||
const result = await deleteProductImage(product.id);
|
||||
if (result.success) {
|
||||
setImage_url("");
|
||||
setImagePreview(null);
|
||||
dispatch({ type: "SET_DRAFT_FIELD", field: "image_url", value: "" });
|
||||
dispatch({ type: "SET_IMAGE_PREVIEW", value: null });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
setError("Product name is required.");
|
||||
dispatch({ type: "SET_ERROR", value: "Product name is required." });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
dispatch({ type: "SET_SAVING", value: true });
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
dispatch({ type: "SET_SAVED", value: false });
|
||||
|
||||
const result = await updateProduct(product.id, brand_id, {
|
||||
name,
|
||||
@@ -202,34 +204,89 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
|
||||
dispatch({ type: "SET_SAVING", value: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
dispatch({ type: "SET_SAVED", value: true });
|
||||
dispatch({ type: "SET_SAVING", value: false });
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
{state.error && (
|
||||
<div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-sm text-[var(--admin-danger)]">
|
||||
{error}
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
{state.saved && (
|
||||
<div className="rounded-xl bg-[var(--admin-primary-soft)] border border-[var(--admin-primary)] p-4 text-sm text-[var(--admin-primary)]">
|
||||
Product updated successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BasicInfoSection
|
||||
name={name}
|
||||
description={description}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
price={price}
|
||||
type={type}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<BrandSection
|
||||
brand_id={brand_id}
|
||||
brands={brands}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<StatusTogglesSection
|
||||
active={active}
|
||||
is_taxable={is_taxable}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<PickupTypeSection
|
||||
pickup_type={pickup_type}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
<ProductImageSection
|
||||
uploading={state.uploading}
|
||||
uploadError={state.uploadError}
|
||||
imagePreview={state.imagePreview}
|
||||
dragOver={state.dragOver}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
/>
|
||||
|
||||
<ProductFormActions saving={state.saving} onSave={handleSave} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BasicInfoSection({
|
||||
name,
|
||||
description,
|
||||
dispatch,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<AdminInput label="Name">
|
||||
<AdminTextInput
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "name", value: e.target.value })}
|
||||
placeholder="Product name"
|
||||
/>
|
||||
</AdminInput>
|
||||
@@ -237,46 +294,84 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
<AdminInput label="Description">
|
||||
<AdminTextarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "description", value: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Product description"
|
||||
/>
|
||||
</AdminInput>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AdminInput label="Price">
|
||||
<AdminTextInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(Number(e.target.value))}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</AdminInput>
|
||||
function PricingSection({
|
||||
price,
|
||||
type,
|
||||
dispatch,
|
||||
}: {
|
||||
price: number;
|
||||
type: string;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AdminInput label="Price">
|
||||
<AdminTextInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "price", value: Number(e.target.value) })}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Type">
|
||||
<AdminTextInput
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="e.g. Sweet Corn"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
<AdminInput label="Type">
|
||||
<AdminTextInput
|
||||
value={type}
|
||||
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "type", value: e.target.value })}
|
||||
placeholder="e.g. Sweet Corn"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
value={brand_id}
|
||||
onChange={(e) => setBrand_id(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
function BrandSection({
|
||||
brand_id,
|
||||
brands,
|
||||
dispatch,
|
||||
}: {
|
||||
brand_id: string;
|
||||
brands: Brand[];
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
value={brand_id}
|
||||
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "brand_id", value: e.target.value })}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTogglesSection({
|
||||
active,
|
||||
is_taxable,
|
||||
dispatch,
|
||||
}: {
|
||||
active: boolean;
|
||||
is_taxable: boolean;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">
|
||||
<span className="block mb-2">Status</span>
|
||||
<button type="button"
|
||||
onClick={() => setActive((v) => !v)}
|
||||
onClick={() => dispatch({ type: "SET_DRAFT_FIELD", field: "active", value: !active })}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
|
||||
@@ -292,7 +387,7 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">
|
||||
<span className="block mb-2">Taxable</span>
|
||||
<button type="button"
|
||||
onClick={() => setIs_taxable((v) => !v)}
|
||||
onClick={() => dispatch({ type: "SET_DRAFT_FIELD", field: "is_taxable", value: !is_taxable })}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
|
||||
is_taxable
|
||||
? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
|
||||
@@ -305,99 +400,132 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
</label>
|
||||
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">Tax is calculated at checkout for shipping orders in your brand's nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<AdminInput
|
||||
function PickupTypeSection({
|
||||
pickup_type,
|
||||
dispatch,
|
||||
}: {
|
||||
pickup_type: string;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<AdminInput
|
||||
label="Pickup Type"
|
||||
helpText="Shed pickup uses the product description as the pickup location — no stop required at checkout."
|
||||
>
|
||||
<AdminSelect
|
||||
value={pickup_type}
|
||||
onChange={(e) => setPickup_type(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "pickup_type", value: e.target.value })}
|
||||
options={[
|
||||
{ value: "scheduled_stop", label: "Scheduled Stop — requires stop selection at checkout" },
|
||||
{ value: "shed", label: "Shed Pickup — uses description as location" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
);
|
||||
}
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</span>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
function ProductImageSection({
|
||||
uploading,
|
||||
uploadError,
|
||||
imagePreview,
|
||||
dragOver,
|
||||
fileInputRef,
|
||||
onFileSelect,
|
||||
onRemoveImage,
|
||||
}: {
|
||||
uploading: boolean;
|
||||
uploadError: string | null;
|
||||
imagePreview: string | null;
|
||||
dragOver: boolean;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onFileSelect: (file: File) => void;
|
||||
onRemoveImage: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</span>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
|
||||
<label
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<>
|
||||
<span className="relative inline-block h-32 w-auto">
|
||||
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input aria-label="File upload"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
||||
|
||||
{imagePreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveImage}
|
||||
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
<label
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) onFileSelect(file);
|
||||
}}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<>
|
||||
<span className="relative inline-block h-32 w-auto">
|
||||
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input aria-label="File upload"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Save button bar — new design tokens */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="ha-btn-primary"
|
||||
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
|
||||
|
||||
{imagePreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemoveImage}
|
||||
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
Remove image
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductFormActions({ saving, onSave }: { saving: boolean; onSave: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
|
||||
<button type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition, useEffect, useRef, useEffectEvent } from "react";
|
||||
import { useReducer, useState, useCallback, useTransition, useEffect, useRef, useEffectEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -66,6 +66,62 @@ async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
|
||||
const EMPTY_BRANDS: { id: string; name: string }[] = [];
|
||||
|
||||
type State = {
|
||||
search: string;
|
||||
statusFilter: "all" | "active" | "inactive";
|
||||
viewMode: ViewMode;
|
||||
showModal: boolean;
|
||||
editingProduct: Product | null;
|
||||
deleteConfirm: string | null;
|
||||
deletingId: string | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_SEARCH"; value: string }
|
||||
| { type: "SET_STATUS_FILTER"; value: "all" | "active" | "inactive" }
|
||||
| { type: "SET_VIEW_MODE"; value: ViewMode }
|
||||
| { type: "OPEN_ADD" }
|
||||
| { type: "OPEN_EDIT"; product: Product }
|
||||
| { type: "CLOSE_MODAL" }
|
||||
| { type: "SET_DELETE_CONFIRM"; id: string | null }
|
||||
| { type: "SET_DELETING"; id: string | null }
|
||||
| { type: "SET_LOADING"; value: boolean };
|
||||
|
||||
const initialState: State = {
|
||||
search: "",
|
||||
statusFilter: "all",
|
||||
viewMode: "table",
|
||||
showModal: false,
|
||||
editingProduct: null,
|
||||
deleteConfirm: null,
|
||||
deletingId: null,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_SEARCH":
|
||||
return { ...state, search: action.value };
|
||||
case "SET_STATUS_FILTER":
|
||||
return { ...state, statusFilter: action.value };
|
||||
case "SET_VIEW_MODE":
|
||||
return { ...state, viewMode: action.value };
|
||||
case "OPEN_ADD":
|
||||
return { ...state, showModal: true, editingProduct: null };
|
||||
case "OPEN_EDIT":
|
||||
return { ...state, showModal: true, editingProduct: action.product };
|
||||
case "CLOSE_MODAL":
|
||||
return { ...state, showModal: false, editingProduct: null };
|
||||
case "SET_DELETE_CONFIRM":
|
||||
return { ...state, deleteConfirm: action.id };
|
||||
case "SET_DELETING":
|
||||
return { ...state, deletingId: action.id };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProductsClient({
|
||||
products,
|
||||
brandId,
|
||||
@@ -80,24 +136,17 @@ export default function ProductsClient({
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(search.toLowerCase());
|
||||
!state.search ||
|
||||
p.name.toLowerCase().includes(state.search.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(state.search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && p.active) ||
|
||||
(statusFilter === "inactive" && !p.active);
|
||||
state.statusFilter === "all" ||
|
||||
(state.statusFilter === "active" && p.active) ||
|
||||
(state.statusFilter === "inactive" && !p.active);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
@@ -117,34 +166,31 @@ export default function ProductsClient({
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
||||
const productIdForUpload = state.editingProduct ? state.editingProduct.id : "__NEW__";
|
||||
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
||||
if (result.success) {
|
||||
return { success: true, imageUrl: result.imageUrl };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
[editingProduct]
|
||||
[state.editingProduct]
|
||||
);
|
||||
|
||||
const openAddModal = useCallback(() => {
|
||||
setEditingProduct(null);
|
||||
setShowModal(true);
|
||||
dispatch({ type: "OPEN_ADD" });
|
||||
}, []);
|
||||
|
||||
const openEditModal = useCallback((product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setShowModal(true);
|
||||
dispatch({ type: "OPEN_EDIT", product });
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
setEditingProduct(null);
|
||||
dispatch({ type: "CLOSE_MODAL" });
|
||||
}, []);
|
||||
|
||||
const handleModalSubmit = useCallback(
|
||||
async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
|
||||
setIsLoading(true);
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
try {
|
||||
const price = parseFloat(values.price);
|
||||
if (isNaN(price) || price < 0) {
|
||||
@@ -161,8 +207,8 @@ export default function ProductsClient({
|
||||
}
|
||||
|
||||
let result;
|
||||
if (editingProduct) {
|
||||
result = await updateProduct(editingProduct.id, effectiveBrandId, {
|
||||
if (state.editingProduct) {
|
||||
result = await updateProduct(state.editingProduct.id, effectiveBrandId, {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
price,
|
||||
@@ -189,7 +235,7 @@ export default function ProductsClient({
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
editingProduct ? "Product updated" : "Product created",
|
||||
state.editingProduct ? "Product updated" : "Product created",
|
||||
`${values.name} has been saved`
|
||||
);
|
||||
startTransition(() => router.refresh());
|
||||
@@ -197,18 +243,18 @@ export default function ProductsClient({
|
||||
} catch {
|
||||
return { success: false, error: "Network error. Please try again." };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
}
|
||||
},
|
||||
[editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
|
||||
[state.editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
|
||||
);
|
||||
|
||||
const handleDelete = async (productId: string) => {
|
||||
setDeletingId(productId);
|
||||
dispatch({ type: "SET_DELETING", id: productId });
|
||||
const result = await deleteProduct(productId, null);
|
||||
setDeletingId(null);
|
||||
dispatch({ type: "SET_DELETING", id: null });
|
||||
if (result.success) {
|
||||
setDeleteConfirm(null);
|
||||
dispatch({ type: "SET_DELETE_CONFIRM", id: null });
|
||||
showSuccess("Product deleted", "The product has been removed");
|
||||
startTransition(() => router.refresh());
|
||||
} else {
|
||||
@@ -225,109 +271,53 @@ export default function ProductsClient({
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Page Header — eyebrow + title + subtitle + primary CTA */}
|
||||
<div className="ha-eyebrow mb-2">Operations</div>
|
||||
<PageHeader
|
||||
icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="Products"
|
||||
subtitle="Manage your product catalog, pricing, and availability."
|
||||
actions={
|
||||
<AdminButton
|
||||
onClick={openAddModal}
|
||||
icon={Icons.plus("h-4 w-4")}
|
||||
>
|
||||
Add product
|
||||
</AdminButton>
|
||||
}
|
||||
<ProductsHeader onAdd={openAddModal} />
|
||||
<ProductsStats
|
||||
total={products.length}
|
||||
active={activeCount}
|
||||
inactive={inactiveCount}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
<ProductsFilterBar
|
||||
search={state.search}
|
||||
statusFilter={state.statusFilter}
|
||||
viewMode={state.viewMode}
|
||||
filterTabs={filterTabs}
|
||||
onSearchChange={(v) => dispatch({ type: "SET_SEARCH", value: v })}
|
||||
onStatusFilterChange={(v) => dispatch({ type: "SET_STATUS_FILTER", value: v })}
|
||||
onViewModeChange={(v) => dispatch({ type: "SET_VIEW_MODE", value: v })}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Total</p>
|
||||
<p
|
||||
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Active</p>
|
||||
<p
|
||||
className="text-xl sm:text-2xl font-bold text-[var(--admin-primary)] mt-1"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Inactive</p>
|
||||
<p
|
||||
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-muted)] mt-1"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
{/* Status Tabs */}
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(value) => setStatusFilter(value as "all" | "active" | "inactive")}
|
||||
tabs={filterTabs}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<AdminSearchInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
placeholder="Search products..."
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
|
||||
{/* View Toggle */}
|
||||
<AdminViewModeTabs
|
||||
activeTab={viewMode}
|
||||
onTabChange={(value) => setViewMode(value as ViewMode)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{viewMode === "table" ? (
|
||||
{state.viewMode === "table" ? (
|
||||
<TableView
|
||||
products={filtered}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(id) => setDeleteConfirm(id)}
|
||||
deleteConfirm={deleteConfirm}
|
||||
onDelete={(id) => dispatch({ type: "SET_DELETE_CONFIRM", id })}
|
||||
deleteConfirm={state.deleteConfirm}
|
||||
onDeleteConfirm={(id) => handleDelete(id)}
|
||||
onDeleteCancel={() => setDeleteConfirm(null)}
|
||||
deletingId={deletingId}
|
||||
onDeleteCancel={() => dispatch({ type: "SET_DELETE_CONFIRM", id: null })}
|
||||
deletingId={state.deletingId}
|
||||
onAdd={openAddModal}
|
||||
/>
|
||||
) : (
|
||||
<CardView
|
||||
products={filtered}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(id) => setDeleteConfirm(id)}
|
||||
deleteConfirm={deleteConfirm}
|
||||
onDelete={(id) => dispatch({ type: "SET_DELETE_CONFIRM", id })}
|
||||
deleteConfirm={state.deleteConfirm}
|
||||
onDeleteConfirm={(id) => handleDelete(id)}
|
||||
onDeleteCancel={() => setDeleteConfirm(null)}
|
||||
deletingId={deletingId}
|
||||
onDeleteCancel={() => dispatch({ type: "SET_DELETE_CONFIRM", id: null })}
|
||||
deletingId={state.deletingId}
|
||||
onAdd={openAddModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal — Atelier des Récoltes (editorial product form) */}
|
||||
<ProductFormModal
|
||||
key={editingProduct?.id ?? "new"}
|
||||
open={showModal}
|
||||
mode={editingProduct ? "edit" : "add"}
|
||||
key={state.editingProduct?.id ?? "new"}
|
||||
open={state.showModal}
|
||||
mode={state.editingProduct ? "edit" : "add"}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleModalSubmit}
|
||||
onUploadImage={handleUploadImage}
|
||||
@@ -335,21 +325,130 @@ export default function ProductsClient({
|
||||
lockBrand={!isPlatformAdmin}
|
||||
lockedBrandId={brandId ?? ""}
|
||||
initial={
|
||||
editingProduct
|
||||
state.editingProduct
|
||||
? {
|
||||
name: editingProduct.name,
|
||||
description: editingProduct.description || "",
|
||||
price: String(editingProduct.price),
|
||||
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
|
||||
brand_id: editingProduct.brand_id,
|
||||
active: editingProduct.active,
|
||||
is_taxable: editingProduct.is_taxable,
|
||||
available_from: editingProduct.available_from ?? null,
|
||||
available_until: editingProduct.available_until ?? null,
|
||||
name: state.editingProduct.name,
|
||||
description: state.editingProduct.description || "",
|
||||
price: String(state.editingProduct.price),
|
||||
type: (state.editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
|
||||
brand_id: state.editingProduct.brand_id,
|
||||
active: state.editingProduct.active,
|
||||
is_taxable: state.editingProduct.is_taxable,
|
||||
available_from: state.editingProduct.available_from ?? null,
|
||||
available_until: state.editingProduct.available_until ?? null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
initialImageUrl={editingProduct?.image_url ?? null}
|
||||
initialImageUrl={state.editingProduct?.image_url ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ProductsHeader({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="ha-eyebrow mb-2">Operations</div>
|
||||
<PageHeader
|
||||
icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="Products"
|
||||
subtitle="Manage your product catalog, pricing, and availability."
|
||||
actions={
|
||||
<AdminButton
|
||||
onClick={onAdd}
|
||||
icon={Icons.plus("h-4 w-4")}
|
||||
>
|
||||
Add product
|
||||
</AdminButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsStats({
|
||||
total,
|
||||
active,
|
||||
inactive,
|
||||
isLoading,
|
||||
}: {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Total</p>
|
||||
<p
|
||||
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Active</p>
|
||||
<p
|
||||
className="text-xl sm:text-2xl font-bold text-[var(--admin-primary)] mt-1"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : active}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Inactive</p>
|
||||
<p
|
||||
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-muted)] mt-1"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactive}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsFilterBar({
|
||||
search,
|
||||
statusFilter,
|
||||
viewMode,
|
||||
filterTabs,
|
||||
onSearchChange,
|
||||
onStatusFilterChange,
|
||||
onViewModeChange,
|
||||
}: {
|
||||
search: string;
|
||||
statusFilter: "all" | "active" | "inactive";
|
||||
viewMode: ViewMode;
|
||||
filterTabs: { value: string; label: string; count: number }[];
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusFilterChange: (value: "all" | "active" | "inactive") => void;
|
||||
onViewModeChange: (value: ViewMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(value) => onStatusFilterChange(value as "all" | "active" | "inactive")}
|
||||
tabs={filterTabs}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<AdminSearchInput
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onClear={() => onSearchChange("")}
|
||||
placeholder="Search products..."
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
|
||||
<AdminViewModeTabs
|
||||
activeTab={viewMode}
|
||||
onTabChange={(value) => onViewModeChange(value as ViewMode)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -390,7 +489,7 @@ function TableView({
|
||||
setMounted(true);
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
}, [setMounted]);
|
||||
|
||||
const onDeleteCancelEvent = useEffectEvent(() => {
|
||||
onDeleteCancel();
|
||||
@@ -411,22 +510,26 @@ function TableView({
|
||||
};
|
||||
}, [deleteConfirm]);
|
||||
|
||||
// Track the previous deleteConfirm value so we can adjust menuPos
|
||||
// inline during render when the prop changes — avoids a stale frame
|
||||
// between the prop change and the effect running.
|
||||
const prevDeleteConfirmRef = useRef<string | null>(deleteConfirm);
|
||||
if (deleteConfirm !== prevDeleteConfirmRef.current) {
|
||||
prevDeleteConfirmRef.current = deleteConfirm;
|
||||
if (!deleteConfirm) {
|
||||
setMenuPos(null);
|
||||
} else {
|
||||
// Reposition the popup while deleteConfirm is active: subscribe to
|
||||
// window scroll/resize so the menu stays anchored to its button. The
|
||||
// initial position is captured at click time (see the row's onClick),
|
||||
// and clearing deleteConfirm also clears menuPos (see onDeleteCancel).
|
||||
useEffect(() => {
|
||||
if (!deleteConfirm) return;
|
||||
const updatePos = () => {
|
||||
const btn = buttonRefs.current[deleteConfirm];
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", updatePos, true);
|
||||
window.addEventListener("resize", updatePos);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updatePos, true);
|
||||
window.removeEventListener("resize", updatePos);
|
||||
};
|
||||
}, [deleteConfirm]);
|
||||
|
||||
// Reposition on scroll/resize so the popup stays anchored to its button.
|
||||
useEffect(() => {
|
||||
@@ -537,7 +640,11 @@ function TableView({
|
||||
</AdminButton>
|
||||
<button type="button"
|
||||
ref={(el) => { buttonRefs.current[product.id] = el; }}
|
||||
onClick={() => onDelete(product.id)}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||
onDelete(product.id);
|
||||
}}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)] transition-colors"
|
||||
aria-label="Product actions"
|
||||
>
|
||||
@@ -576,13 +683,19 @@ function TableView({
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button type="button"
|
||||
onClick={onDeleteCancel}
|
||||
onClick={() => {
|
||||
setMenuPos(null);
|
||||
onDeleteCancel();
|
||||
}}
|
||||
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
|
||||
aria-label="Cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<AdminButton
|
||||
onClick={() => onDeleteConfirm(openProduct.id)}
|
||||
onClick={() => {
|
||||
setMenuPos(null);
|
||||
onDeleteConfirm(openProduct.id);
|
||||
}}
|
||||
disabled={deletingId === openProduct.id}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@@ -752,4 +865,4 @@ function CardView({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useReducer, useRef, useCallback } from "react";
|
||||
import { createStopsBatch } from "@/actions/stops";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
import type { ParsedStopRow } from "@/lib/csv-parsers";
|
||||
|
||||
type ParsedStop = Omit<ParsedStopRow, "_rowIndex" | "_warnings">;
|
||||
|
||||
type Step = "idle" | "parsing" | "review" | "importing" | "done" | "error";
|
||||
|
||||
type State = {
|
||||
step: Step;
|
||||
error: string | null;
|
||||
parsedStops: ParsedStop[];
|
||||
warnings: string[];
|
||||
dragOver: boolean;
|
||||
useAI: boolean;
|
||||
created: number;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "START_PARSE" }
|
||||
| { type: "PARSE_SUCCESS"; stops: ParsedStop[]; warnings: string[] }
|
||||
| { type: "PARSE_FAIL"; error: string }
|
||||
| { type: "SET_ERROR"; error: string | null }
|
||||
| { type: "SET_DRAG_OVER"; value: boolean }
|
||||
| { type: "SET_USE_AI"; value: boolean }
|
||||
| { type: "UPDATE_STOP"; idx: number; field: keyof ParsedStop; value: string }
|
||||
| { type: "REMOVE_STOP"; idx: number }
|
||||
| { type: "START_IMPORT" }
|
||||
| { type: "IMPORT_FAIL"; error: string }
|
||||
| { type: "IMPORT_SUCCESS"; created: number }
|
||||
| { type: "RESET" };
|
||||
|
||||
const initialState: State = {
|
||||
step: "idle",
|
||||
error: null,
|
||||
parsedStops: [],
|
||||
warnings: [],
|
||||
dragOver: false,
|
||||
useAI: false,
|
||||
created: 0,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "START_PARSE":
|
||||
return { ...state, step: "parsing", error: null };
|
||||
case "PARSE_SUCCESS":
|
||||
return { ...state, step: "review", parsedStops: action.stops, warnings: action.warnings };
|
||||
case "PARSE_FAIL":
|
||||
return { ...state, step: "idle", error: action.error };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.error };
|
||||
case "SET_DRAG_OVER":
|
||||
return { ...state, dragOver: action.value };
|
||||
case "SET_USE_AI":
|
||||
return { ...state, useAI: action.value };
|
||||
case "UPDATE_STOP": {
|
||||
const next = [...state.parsedStops];
|
||||
next[action.idx] = { ...next[action.idx], [action.field]: action.value };
|
||||
return { ...state, parsedStops: next };
|
||||
}
|
||||
case "REMOVE_STOP":
|
||||
return { ...state, parsedStops: state.parsedStops.filter((_, i) => i !== action.idx) };
|
||||
case "START_IMPORT":
|
||||
return { ...state, step: "importing", error: null };
|
||||
case "IMPORT_FAIL":
|
||||
return { ...state, step: "review", error: action.error };
|
||||
case "IMPORT_SUCCESS":
|
||||
return { ...state, step: "done", created: action.created };
|
||||
case "RESET":
|
||||
return { ...state, step: "idle", parsedStops: [], warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
onClose: () => void;
|
||||
@@ -16,25 +81,17 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function ScheduleImportModal({ brandId, onClose, onComplete }: Props) {
|
||||
const [step, setStep] = useState<Step>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [parsedStops, setParsedStops] = useState<ParsedStop[]>([]);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [useAI, setUseAI] = useState(false);
|
||||
const [created, setCreated] = useState(0);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
setError(null);
|
||||
setStep("parsing");
|
||||
dispatch({ type: "START_PARSE" });
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch {
|
||||
setError("Could not read file. Try a different format.");
|
||||
setStep("idle");
|
||||
dispatch({ type: "PARSE_FAIL", error: "Could not read file. Try a different format." });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,36 +99,34 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
||||
const res = await fetch("/api/stops/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text, brandId, useAI }),
|
||||
body: JSON.stringify({ text, brandId, useAI: state.useAI }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || (data.error && data.stops?.length === 0)) {
|
||||
setError(data.error ?? "Parsing failed. Try a CSV file.");
|
||||
setStep("idle");
|
||||
dispatch({ type: "PARSE_FAIL", error: data.error ?? "Parsing failed. Try a CSV file." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.stops || data.stops.length === 0) {
|
||||
setError("No stops found in file. Check that columns include: city, state, location, date, time.");
|
||||
setStep("idle");
|
||||
dispatch({
|
||||
type: "PARSE_FAIL",
|
||||
error: "No stops found in file. Check that columns include: city, state, location, date, time.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setParsedStops(data.stops);
|
||||
setWarnings(data.warnings ?? []);
|
||||
setStep("review");
|
||||
dispatch({ type: "PARSE_SUCCESS", stops: data.stops, warnings: data.warnings ?? [] });
|
||||
} catch {
|
||||
setError("Network error while parsing file.");
|
||||
setStep("idle");
|
||||
dispatch({ type: "PARSE_FAIL", error: "Network error while parsing file." });
|
||||
}
|
||||
}, [brandId, useAI]);
|
||||
}, [brandId, state.useAI]);
|
||||
|
||||
function handleFile(file: File) {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (!["csv", "txt", "json"].includes(ext)) {
|
||||
setError("Unsupported file type. Please upload a CSV, TXT, or JSON file.");
|
||||
dispatch({ type: "SET_ERROR", error: "Unsupported file type. Please upload a CSV, TXT, or JSON file." });
|
||||
return;
|
||||
}
|
||||
processFile(file);
|
||||
@@ -79,7 +134,7 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
dispatch({ type: "SET_DRAG_OVER", value: false });
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
@@ -89,43 +144,26 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
|
||||
function updateStop(idx: number, field: keyof ParsedStop, value: string) {
|
||||
setParsedStops((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function removeStop(idx: number) {
|
||||
setParsedStops((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (parsedStops.length === 0) return;
|
||||
setStep("importing");
|
||||
setError(null);
|
||||
if (state.parsedStops.length === 0) return;
|
||||
dispatch({ type: "START_IMPORT" });
|
||||
|
||||
const result = await createStopsBatch(
|
||||
brandId,
|
||||
parsedStops.map(({ city, state, location, date, time, address, zip, notes }) => ({
|
||||
city, state, location, date, time, address, zip, notes,
|
||||
state.parsedStops.map(({ city, state: st, location, date, time, address, zip, notes }) => ({
|
||||
city, state: st, location, date, time, address, zip, notes,
|
||||
}))
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Import failed");
|
||||
setStep("review");
|
||||
dispatch({ type: "IMPORT_FAIL", error: result.error ?? "Import failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
setCreated(result.created);
|
||||
setStep("done");
|
||||
dispatch({ type: "IMPORT_SUCCESS", created: result.created });
|
||||
onComplete(result.created);
|
||||
}
|
||||
|
||||
const hasWarnings = warnings.length > 0;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title="📥 Import Schedule"
|
||||
@@ -133,253 +171,359 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{step === "idle" && (
|
||||
<>
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI toggle */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
|
||||
<button type="button"
|
||||
onClick={() => setUseAI((v) => !v)}
|
||||
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
||||
useAI
|
||||
? "bg-violet-100 border border-violet-300 text-violet-700"
|
||||
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
|
||||
}`}
|
||||
aria-label="Use AI for text/PDF parsing">
|
||||
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
|
||||
{useAI ? "✓" : "○"}
|
||||
</span>
|
||||
Use AI for text/PDF parsing
|
||||
</button>
|
||||
<span className="text-xs text-stone-500">
|
||||
{useAI ? "AI will parse unstructured text. Requires OPENAI_API_KEY." : "Best results with CSV."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<label
|
||||
htmlFor="schedule-file-upload"
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all ${
|
||||
dragOver
|
||||
? "border-emerald-500 bg-emerald-50"
|
||||
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
|
||||
<svg className={`h-6 w-6 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-stone-700">Drop your schedule file here</p>
|
||||
<p className="mt-1 text-xs text-stone-500">or click to browse — CSV, TXT, JSON</p>
|
||||
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
|
||||
CSV: city, state, location, date, time, address, zip, notes
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<input id="schedule-file-upload"
|
||||
aria-label="File upload"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.txt,.json"
|
||||
className="sr-only"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
{state.step === "idle" && (
|
||||
<IdleStep
|
||||
error={state.error}
|
||||
useAI={state.useAI}
|
||||
dragOver={state.dragOver}
|
||||
fileInputRef={fileInputRef}
|
||||
onToggleAI={() => dispatch({ type: "SET_USE_AI", value: !state.useAI })}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
dispatch({ type: "SET_DRAG_OVER", value: true });
|
||||
}}
|
||||
onDragLeave={() => dispatch({ type: "SET_DRAG_OVER", value: false })}
|
||||
onDrop={handleDrop}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "parsing" && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-stone-600">Parsing file…</span>
|
||||
</div>
|
||||
</div>
|
||||
{state.step === "parsing" && <ParsingStep />}
|
||||
{state.step === "review" && (
|
||||
<ReviewStep
|
||||
stops={state.parsedStops}
|
||||
warnings={state.warnings}
|
||||
error={state.error}
|
||||
onUpdateStop={(idx, field, value) =>
|
||||
dispatch({ type: "UPDATE_STOP", idx, field, value })
|
||||
}
|
||||
onRemoveStop={(idx) => dispatch({ type: "REMOVE_STOP", idx })}
|
||||
onCancel={() => dispatch({ type: "RESET" })}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-amber-800">Parsing warnings:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
|
||||
{warnings.slice(0, 5).map((w, i) => (
|
||||
<li key={`${w}-${i}`}>{w}</li>
|
||||
))}
|
||||
{warnings.length > 5 && <li>…and {warnings.length - 5} more</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-stone-700">
|
||||
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found — review and edit
|
||||
</p>
|
||||
<span className="text-xs text-stone-400">All will be created as drafts</span>
|
||||
</div>
|
||||
|
||||
{/* Review table */}
|
||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-stone-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-stone-50 border-b border-stone-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">City</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">State</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Location</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Date</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Time</th>
|
||||
<th className="w-8 px-3 py-2.5" aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-50">
|
||||
{parsedStops.map((stop, idx) => (
|
||||
<tr key={`${stop.city}-${stop.state}-${stop.date}-${stop.time}-${idx}`} className="hover:bg-stone-50/50 transition-colors">
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.city}
|
||||
onChange={(e) => updateStop(idx, "city", e.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.state}
|
||||
onChange={(e) => updateStop(idx, "state", e.target.value)}
|
||||
className="w-12 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.location}
|
||||
onChange={(e) => updateStop(idx, "location", e.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.date}
|
||||
onChange={(e) => updateStop(idx, "date", e.target.value)}
|
||||
className="w-24 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.time}
|
||||
onChange={(e) => updateStop(idx, "time", e.target.value)}
|
||||
className="w-16 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button type="button"
|
||||
onClick={() => removeStop(idx)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 transition-colors"
|
||||
aria-label="Close">
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-stone-100">
|
||||
<p className="text-xs text-stone-500">
|
||||
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
aria-label="Cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleImport}
|
||||
disabled={parsedStops.length === 0}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2 text-sm font-bold text-white disabled:opacity-50 transition-colors"
|
||||
aria-label="Create Stop">
|
||||
Create {parsedStops.length} Stop{parsedStops.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{state.step === "importing" && <ImportingStep />}
|
||||
{state.step === "done" && (
|
||||
<DoneStep created={state.created} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{step === "importing" && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-stone-600">Creating draft stops…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-50">
|
||||
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-bold text-stone-950">
|
||||
{created} draft stop{created !== 1 ? "s" : ""} created
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
Review them in the stops list and publish when ready.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2.5 text-sm font-bold text-white transition-colors"
|
||||
aria-label="Back to Stops">
|
||||
Back to Stops
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "error" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50">
|
||||
<svg className="h-7 w-7 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700">{error}</p>
|
||||
<button type="button"
|
||||
onClick={() => setStep("idle")}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
aria-label="Try Again">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{state.step === "error" && (
|
||||
<ErrorStep
|
||||
error={state.error ?? ""}
|
||||
onRetry={() => dispatch({ type: "START_PARSE" })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
type IdleStepProps = {
|
||||
error: string | null;
|
||||
useAI: boolean;
|
||||
dragOver: boolean;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onToggleAI: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
function IdleStep({
|
||||
error,
|
||||
useAI,
|
||||
dragOver,
|
||||
fileInputRef,
|
||||
onToggleAI,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onChange,
|
||||
}: IdleStepProps) {
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI toggle */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
|
||||
<button type="button"
|
||||
onClick={onToggleAI}
|
||||
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
||||
useAI
|
||||
? "bg-violet-100 border border-violet-300 text-violet-700"
|
||||
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
|
||||
}`}
|
||||
aria-label="Use AI for text/PDF parsing">
|
||||
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
|
||||
{useAI ? "✓" : "○"}
|
||||
</span>
|
||||
Use AI for text/PDF parsing
|
||||
</button>
|
||||
<span className="text-xs text-stone-500">
|
||||
{useAI ? "AI will parse unstructured text. Requires OPENAI_API_KEY." : "Best results with CSV."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<label
|
||||
htmlFor="schedule-file-upload"
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all ${
|
||||
dragOver
|
||||
? "border-emerald-500 bg-emerald-50"
|
||||
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
|
||||
<svg className={`h-6 w-6 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-stone-700">Drop your schedule file here</p>
|
||||
<p className="mt-1 text-xs text-stone-500">or click to browse — CSV, TXT, JSON</p>
|
||||
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
|
||||
CSV: city, state, location, date, time, address, zip, notes
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<input id="schedule-file-upload"
|
||||
aria-label="File upload"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.txt,.json"
|
||||
className="sr-only"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ParsingStep() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-stone-600">Parsing file…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ReviewStepProps = {
|
||||
stops: ParsedStop[];
|
||||
warnings: string[];
|
||||
error: string | null;
|
||||
onUpdateStop: (idx: number, field: keyof ParsedStop, value: string) => void;
|
||||
onRemoveStop: (idx: number) => void;
|
||||
onCancel: () => void;
|
||||
onImport: () => void;
|
||||
};
|
||||
|
||||
function ReviewStep({
|
||||
stops,
|
||||
warnings,
|
||||
error,
|
||||
onUpdateStop,
|
||||
onRemoveStop,
|
||||
onCancel,
|
||||
onImport,
|
||||
}: ReviewStepProps) {
|
||||
const hasWarnings = warnings.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-amber-800">Parsing warnings:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
|
||||
{warnings.slice(0, 5).map((w, i) => (
|
||||
<li key={`${w}-${i}`}>{w}</li>
|
||||
))}
|
||||
{warnings.length > 5 && <li>…and {warnings.length - 5} more</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-stone-700">
|
||||
{stops.length} stop{stops.length !== 1 ? "s" : ""} found — review and edit
|
||||
</p>
|
||||
<span className="text-xs text-stone-400">All will be created as drafts</span>
|
||||
</div>
|
||||
|
||||
<ReviewTable stops={stops} onUpdateStop={onUpdateStop} onRemoveStop={onRemoveStop} />
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-stone-100">
|
||||
<p className="text-xs text-stone-500">
|
||||
{stops.length} draft stop{stops.length !== 1 ? "s" : ""} will be created
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
aria-label="Cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onImport}
|
||||
disabled={stops.length === 0}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2 text-sm font-bold text-white disabled:opacity-50 transition-colors"
|
||||
aria-label={`Create ${stops.length} Stop${stops.length !== 1 ? "s" : ""}`}>
|
||||
Create {stops.length} Stop{stops.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ReviewTableProps = {
|
||||
stops: ParsedStop[];
|
||||
onUpdateStop: (idx: number, field: keyof ParsedStop, value: string) => void;
|
||||
onRemoveStop: (idx: number) => void;
|
||||
};
|
||||
|
||||
function ReviewTable({ stops, onUpdateStop, onRemoveStop }: ReviewTableProps) {
|
||||
return (
|
||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-stone-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-stone-50 border-b border-stone-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">City</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">State</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Location</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Date</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Time</th>
|
||||
<th className="w-8 px-3 py-2.5" aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-50">
|
||||
{stops.map((stop, idx) => (
|
||||
<tr key={`${stop.city}-${stop.state}-${stop.date}-${stop.time}-${idx}`} className="hover:bg-stone-50/50 transition-colors">
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.city}
|
||||
onChange={(e) => onUpdateStop(idx, "city", e.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.state}
|
||||
onChange={(e) => onUpdateStop(idx, "state", e.target.value)}
|
||||
className="w-12 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.location}
|
||||
onChange={(e) => onUpdateStop(idx, "location", e.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.date}
|
||||
onChange={(e) => onUpdateStop(idx, "date", e.target.value)}
|
||||
className="w-24 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input aria-label="Input"
|
||||
value={stop.time}
|
||||
onChange={(e) => onUpdateStop(idx, "time", e.target.value)}
|
||||
className="w-16 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button type="button"
|
||||
onClick={() => onRemoveStop(idx)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 transition-colors"
|
||||
aria-label="Close">
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportingStep() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-stone-600">Creating draft stops…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoneStep({ created, onClose }: { created: number; onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-50">
|
||||
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-bold text-stone-950">
|
||||
{created} draft stop{created !== 1 ? "s" : ""} created
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
Review them in the stops list and publish when ready.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2.5 text-sm font-bold text-white transition-colors"
|
||||
aria-label="Back to Stops">
|
||||
Back to Stops
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorStep({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50">
|
||||
<svg className="h-7 w-7 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700">{error}</p>
|
||||
<button type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
aria-label="Try Again">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useReducer, useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
getShippingSettings,
|
||||
saveShippingSettings,
|
||||
@@ -33,6 +33,114 @@ interface ValidationErrors {
|
||||
|
||||
const EMPTY_BRANDS: { id: string; name: string }[] = [];
|
||||
|
||||
type FormField =
|
||||
| "fedexAccountNumber"
|
||||
| "fedexApiKey"
|
||||
| "fedexApiSecret"
|
||||
| "fedexUseProduction"
|
||||
| "defaultServiceType"
|
||||
| "refrigeratedHandlingNotes"
|
||||
| "fragileHandlingNotes";
|
||||
|
||||
type State = {
|
||||
fedexAccountNumber: string;
|
||||
fedexApiKey: string;
|
||||
fedexApiSecret: string;
|
||||
fedexUseProduction: boolean;
|
||||
defaultServiceType: string;
|
||||
refrigeratedHandlingNotes: string;
|
||||
fragileHandlingNotes: string;
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
error: string | null;
|
||||
testing: boolean;
|
||||
testResult: { success: boolean; message: string } | null;
|
||||
showSecret: boolean;
|
||||
dirty: boolean;
|
||||
errors: ValidationErrors;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "FIELD_CHANGE"; field: FormField; value: string | boolean }
|
||||
| { type: "SET_SHOW_SECRET"; value: boolean }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SET_ERRORS"; value: ValidationErrors }
|
||||
| { type: "SET_DIRTY"; value: boolean }
|
||||
| { type: "SAVE_START" }
|
||||
| { type: "SAVE_FAIL"; error: string }
|
||||
| { type: "SAVE_SUCCESS" }
|
||||
| { type: "CLEAR_SAVED" }
|
||||
| { type: "TEST_INLINE_FAIL"; message: string }
|
||||
| { type: "TEST_START" }
|
||||
| { type: "TEST_DONE"; success: boolean; message: string };
|
||||
|
||||
function makeInitialState(settings: ShippingSettings | null): State {
|
||||
return {
|
||||
fedexAccountNumber: settings?.fedex_account_number ?? "",
|
||||
fedexApiKey: settings?.fedex_api_key ?? "",
|
||||
fedexApiSecret: settings?.fedex_api_secret ?? "",
|
||||
fedexUseProduction: settings?.fedex_use_production ?? false,
|
||||
defaultServiceType: settings?.default_service_type ?? "FEDEX_GROUND",
|
||||
refrigeratedHandlingNotes:
|
||||
settings?.refrigerated_handling_notes ??
|
||||
"Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions.",
|
||||
fragileHandlingNotes: settings?.fragile_handling_notes ?? "",
|
||||
saving: false,
|
||||
saved: false,
|
||||
error: null,
|
||||
testing: false,
|
||||
testResult: null,
|
||||
showSecret: false,
|
||||
dirty: false,
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "FIELD_CHANGE": {
|
||||
const nextErrors: ValidationErrors =
|
||||
action.field === "fedexAccountNumber" ||
|
||||
action.field === "fedexApiKey" ||
|
||||
action.field === "fedexApiSecret"
|
||||
? { ...state.errors, [action.field]: undefined }
|
||||
: state.errors;
|
||||
return {
|
||||
...state,
|
||||
[action.field]: action.value,
|
||||
dirty: true,
|
||||
errors: nextErrors,
|
||||
};
|
||||
}
|
||||
case "SET_SHOW_SECRET":
|
||||
return { ...state, showSecret: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SET_ERRORS":
|
||||
return { ...state, errors: action.value };
|
||||
case "SET_DIRTY":
|
||||
return { ...state, dirty: action.value };
|
||||
case "SAVE_START":
|
||||
return { ...state, saving: true, error: null, saved: false, testResult: null };
|
||||
case "SAVE_FAIL":
|
||||
return { ...state, saving: false, error: action.error };
|
||||
case "SAVE_SUCCESS":
|
||||
return { ...state, saving: false, saved: true, dirty: false };
|
||||
case "CLEAR_SAVED":
|
||||
return { ...state, saved: false };
|
||||
case "TEST_INLINE_FAIL":
|
||||
return { ...state, testResult: { success: false, message: action.message } };
|
||||
case "TEST_START":
|
||||
return { ...state, testing: true, testResult: null };
|
||||
case "TEST_DONE":
|
||||
return {
|
||||
...state,
|
||||
testing: false,
|
||||
testResult: { success: action.success, message: action.message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function ShippingSettingsForm(props: Props) {
|
||||
// Non-platform-admin case: brand is fixed, just pass props through.
|
||||
// The body's `key` forces a remount if the brand id ever changes.
|
||||
@@ -128,56 +236,40 @@ function ShippingSettingsFormBody({
|
||||
brandId: string;
|
||||
settings: ShippingSettings | null;
|
||||
}) {
|
||||
const [fedexAccountNumber, setFedexAccountNumber] = useState(settings?.fedex_account_number ?? "");
|
||||
const [fedexApiKey, setFedexApiKey] = useState(settings?.fedex_api_key ?? "");
|
||||
const [fedexApiSecret, setFedexApiSecret] = useState(settings?.fedex_api_secret ?? "");
|
||||
const [fedexUseProduction, setFedexUseProduction] = useState(settings?.fedex_use_production ?? false);
|
||||
const [defaultServiceType, setDefaultServiceType] = useState(settings?.default_service_type ?? "FEDEX_GROUND");
|
||||
const [refrigeratedHandlingNotes, setRefrigeratedHandlingNotes] = useState(
|
||||
settings?.refrigerated_handling_notes ?? "Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions."
|
||||
);
|
||||
const [fragileHandlingNotes, setFragileHandlingNotes] = useState(settings?.fragile_handling_notes ?? "");
|
||||
const [state, dispatch] = useReducer(reducer, settings, makeInitialState);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
|
||||
const isConfigured = !!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
|
||||
const isConfigured =
|
||||
!!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
|
||||
|
||||
// Track dirty state — derived during render per React docs.
|
||||
const computedDirty =
|
||||
fedexAccountNumber !== (settings?.fedex_account_number ?? "") ||
|
||||
fedexApiKey !== (settings?.fedex_api_key ?? "") ||
|
||||
fedexApiSecret !== (settings?.fedex_api_secret ?? "") ||
|
||||
fedexUseProduction !== (settings?.fedex_use_production ?? false) ||
|
||||
defaultServiceType !== (settings?.default_service_type ?? "FEDEX_GROUND") ||
|
||||
refrigeratedHandlingNotes !== (settings?.refrigerated_handling_notes ?? "") ||
|
||||
fragileHandlingNotes !== (settings?.fragile_handling_notes ?? "");
|
||||
if (dirty !== computedDirty) {
|
||||
setDirty(computedDirty);
|
||||
state.fedexAccountNumber !== (settings?.fedex_account_number ?? "") ||
|
||||
state.fedexApiKey !== (settings?.fedex_api_key ?? "") ||
|
||||
state.fedexApiSecret !== (settings?.fedex_api_secret ?? "") ||
|
||||
state.fedexUseProduction !== (settings?.fedex_use_production ?? false) ||
|
||||
state.defaultServiceType !== (settings?.default_service_type ?? "FEDEX_GROUND") ||
|
||||
state.refrigeratedHandlingNotes !== (settings?.refrigerated_handling_notes ?? "") ||
|
||||
state.fragileHandlingNotes !== (settings?.fragile_handling_notes ?? "");
|
||||
if (state.dirty !== computedDirty) {
|
||||
dispatch({ type: "SET_DIRTY", value: computedDirty });
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const newErrors: ValidationErrors = {};
|
||||
|
||||
if (fedexAccountNumber && fedexAccountNumber.length !== 12) {
|
||||
if (state.fedexAccountNumber && state.fedexAccountNumber.length !== 12) {
|
||||
newErrors.fedexAccountNumber = "FedEx account number must be 12 digits";
|
||||
}
|
||||
|
||||
if (fedexApiKey && fedexApiKey.length < 10) {
|
||||
if (state.fedexApiKey && state.fedexApiKey.length < 10) {
|
||||
newErrors.fedexApiKey = "API Key appears too short - verify it from FedEx developer portal";
|
||||
}
|
||||
|
||||
if (fedexApiSecret && fedexApiSecret.length < 10) {
|
||||
if (state.fedexApiSecret && state.fedexApiSecret.length < 10) {
|
||||
newErrors.fedexApiSecret = "API Secret appears too short - verify it from FedEx developer portal";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
dispatch({ type: "SET_ERRORS", value: newErrors });
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
@@ -185,87 +277,152 @@ function ShippingSettingsFormBody({
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
setError("Please correct the errors below before saving.");
|
||||
dispatch({ type: "SET_ERROR", value: "Please correct the errors below before saving." });
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
setTestResult(null);
|
||||
dispatch({ type: "SAVE_START" });
|
||||
|
||||
const result = await saveShippingSettings({
|
||||
brandId,
|
||||
fedexAccountNumber: fedexAccountNumber || undefined,
|
||||
fedexApiKey: fedexApiKey || undefined,
|
||||
fedexApiSecret: fedexApiSecret || undefined,
|
||||
fedexUseProduction,
|
||||
defaultServiceType,
|
||||
refrigeratedHandlingNotes: refrigeratedHandlingNotes || undefined,
|
||||
fragileHandlingNotes: fragileHandlingNotes || undefined,
|
||||
fedexAccountNumber: state.fedexAccountNumber || undefined,
|
||||
fedexApiKey: state.fedexApiKey || undefined,
|
||||
fedexApiSecret: state.fedexApiSecret || undefined,
|
||||
fedexUseProduction: state.fedexUseProduction,
|
||||
defaultServiceType: state.defaultServiceType,
|
||||
refrigeratedHandlingNotes: state.refrigeratedHandlingNotes || undefined,
|
||||
fragileHandlingNotes: state.fragileHandlingNotes || undefined,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setDirty(false);
|
||||
setTimeout(() => setSaved(false), 4000);
|
||||
dispatch({ type: "SAVE_FAIL", error: result.error ?? "Failed to save" });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: "SAVE_SUCCESS" });
|
||||
setTimeout(() => dispatch({ type: "CLEAR_SAVED" }), 4000);
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (!fedexApiKey || !fedexApiSecret) {
|
||||
setTestResult({ success: false, message: "Please enter both API Key and API Secret before testing." });
|
||||
if (!state.fedexApiKey || !state.fedexApiSecret) {
|
||||
dispatch({
|
||||
type: "TEST_INLINE_FAIL",
|
||||
message: "Please enter both API Key and API Secret before testing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
const result = await testFedExConnection(fedexApiKey, fedexApiSecret, fedexUseProduction);
|
||||
setTesting(false);
|
||||
setTestResult({ success: result.success, message: result.success ? result.message : result.error ?? "Connection failed" });
|
||||
dispatch({ type: "TEST_START" });
|
||||
const result = await testFedExConnection(
|
||||
state.fedexApiKey,
|
||||
state.fedexApiSecret,
|
||||
state.fedexUseProduction
|
||||
);
|
||||
dispatch({
|
||||
type: "TEST_DONE",
|
||||
success: result.success,
|
||||
message: result.success ? result.message : result.error ?? "Connection failed",
|
||||
});
|
||||
}
|
||||
|
||||
function handleFieldChange(field: FormField, value: string | boolean) {
|
||||
dispatch({ type: "FIELD_CHANGE", field, value });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
<ConnectionStatusBanner isConfigured={isConfigured} useProduction={settings?.fedex_use_production ?? false} />
|
||||
|
||||
<FormMessages error={state.error} saved={state.saved} testResult={state.testResult} />
|
||||
|
||||
<FedExCredentialsSection
|
||||
fedexAccountNumber={state.fedexAccountNumber}
|
||||
fedexApiKey={state.fedexApiKey}
|
||||
fedexApiSecret={state.fedexApiSecret}
|
||||
fedexUseProduction={state.fedexUseProduction}
|
||||
showSecret={state.showSecret}
|
||||
testing={state.testing}
|
||||
errors={state.errors}
|
||||
onFieldChange={handleFieldChange}
|
||||
onToggleShowSecret={() => dispatch({ type: "SET_SHOW_SECRET", value: !state.showSecret })}
|
||||
onTestConnection={handleTestConnection}
|
||||
/>
|
||||
|
||||
<DefaultShippingSection
|
||||
defaultServiceType={state.defaultServiceType}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
<HandlingNotesSection
|
||||
refrigeratedHandlingNotes={state.refrigeratedHandlingNotes}
|
||||
fragileHandlingNotes={state.fragileHandlingNotes}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
<SaveButtonRow saving={state.saving} dirty={state.dirty} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionStatusBanner({
|
||||
isConfigured,
|
||||
useProduction,
|
||||
}: {
|
||||
isConfigured: boolean;
|
||||
useProduction: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium border"
|
||||
style={{
|
||||
backgroundColor: isConfigured ? "rgba(16, 185, 129, 0.1)" : "rgba(245, 158, 11, 0.1)",
|
||||
borderColor: isConfigured ? "var(--admin-success)" : "rgba(245, 158, 11, 0.3)",
|
||||
color: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)" }}
|
||||
/>
|
||||
{isConfigured ? (
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold">FedEx Connected</span>
|
||||
<span className="ml-2 text-[var(--admin-text-muted)]">
|
||||
— {useProduction ? "Production Mode" : "Sandbox/Test Mode"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>FedEx Not Configured — enter credentials below to enable shipping rates</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessages({
|
||||
error,
|
||||
saved,
|
||||
testResult,
|
||||
}: {
|
||||
error: string | null;
|
||||
saved: boolean;
|
||||
testResult: { success: boolean; message: string } | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300">{error}</div>
|
||||
<div
|
||||
className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status banner */}
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium border"
|
||||
style={{
|
||||
backgroundColor: isConfigured ? "rgba(16, 185, 129, 0.1)" : "rgba(245, 158, 11, 0.1)",
|
||||
borderColor: isConfigured ? "var(--admin-success)" : "rgba(245, 158, 11, 0.3)",
|
||||
color: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)" }}
|
||||
/>
|
||||
{isConfigured ? (
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold">FedEx Connected</span>
|
||||
<span className="ml-2 text-[var(--admin-text-muted)]">
|
||||
— {settings?.fedex_use_production ? "Production Mode" : "Sandbox/Test Mode"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>FedEx Not Configured — enter credentials below to enable shipping rates</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error/Success messages */}
|
||||
{error && (
|
||||
<div
|
||||
<div
|
||||
className="rounded-xl p-4 text-sm border flex items-start gap-3"
|
||||
style={{
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
borderColor: "rgba(239, 68, 68, 0.3)",
|
||||
color: "rgb(239, 68, 68)"
|
||||
style={{
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
borderColor: "rgba(239, 68, 68, 0.3)",
|
||||
color: "rgb(239, 68, 68)",
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -275,12 +432,12 @@ function ShippingSettingsFormBody({
|
||||
</div>
|
||||
)}
|
||||
{saved && (
|
||||
<div
|
||||
<div
|
||||
className="rounded-xl p-4 text-sm border flex items-center gap-3"
|
||||
style={{
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
borderColor: "rgba(16, 185, 129, 0.3)",
|
||||
color: "var(--admin-success)"
|
||||
style={{
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
borderColor: "rgba(16, 185, 129, 0.3)",
|
||||
color: "var(--admin-success)",
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -290,12 +447,12 @@ function ShippingSettingsFormBody({
|
||||
</div>
|
||||
)}
|
||||
{testResult && (
|
||||
<div
|
||||
<div
|
||||
className="rounded-xl p-4 text-sm border flex items-start gap-3"
|
||||
style={{
|
||||
backgroundColor: testResult.success ? "rgba(16, 185, 129, 0.1)" : "rgba(239, 68, 68, 0.1)",
|
||||
borderColor: testResult.success ? "rgba(16, 185, 129, 0.3)" : "rgba(239, 68, 68, 0.3)",
|
||||
color: testResult.success ? "var(--admin-success)" : "rgb(239, 68, 68)"
|
||||
style={{
|
||||
backgroundColor: testResult.success ? "rgba(16, 185, 129, 0.1)" : "rgba(239, 68, 68, 0.1)",
|
||||
borderColor: testResult.success ? "rgba(16, 185, 129, 0.3)" : "rgba(239, 68, 68, 0.3)",
|
||||
color: testResult.success ? "var(--admin-success)" : "rgb(239, 68, 68)",
|
||||
}}
|
||||
>
|
||||
{testResult.success ? (
|
||||
@@ -310,225 +467,237 @@ function ShippingSettingsFormBody({
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{/* FedEx credentials */}
|
||||
<div
|
||||
className="space-y-5 rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">FedEx API Credentials</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Get your credentials from{" "}
|
||||
<a
|
||||
href="https://developer.fedex.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
style={{ color: "var(--admin-accent)" }}
|
||||
>
|
||||
developer.fedex.com
|
||||
</a>
|
||||
. These are used for shipping fresh produce.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AdminInput
|
||||
label="FedEx Account Number"
|
||||
required
|
||||
error={errors.fedexAccountNumber}
|
||||
helpText="12-digit FedEx account number"
|
||||
function FedExCredentialsSection({
|
||||
fedexAccountNumber,
|
||||
fedexApiKey,
|
||||
fedexApiSecret,
|
||||
fedexUseProduction,
|
||||
showSecret,
|
||||
testing,
|
||||
errors,
|
||||
onFieldChange,
|
||||
onToggleShowSecret,
|
||||
onTestConnection,
|
||||
}: {
|
||||
fedexAccountNumber: string;
|
||||
fedexApiKey: string;
|
||||
fedexApiSecret: string;
|
||||
fedexUseProduction: boolean;
|
||||
showSecret: boolean;
|
||||
testing: boolean;
|
||||
errors: ValidationErrors;
|
||||
onFieldChange: (field: FormField, value: string | boolean) => void;
|
||||
onToggleShowSecret: () => void;
|
||||
onTestConnection: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="space-y-5 rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
borderColor: "var(--admin-border)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">FedEx API Credentials</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Get your credentials from{" "}
|
||||
<a
|
||||
href="https://developer.fedex.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
style={{ color: "var(--admin-accent)" }}
|
||||
>
|
||||
<AdminTextInput
|
||||
value={fedexAccountNumber}
|
||||
onChange={(e) => {
|
||||
setFedexAccountNumber(e.target.value);
|
||||
setDirty(true);
|
||||
if (errors.fedexAccountNumber) {
|
||||
setErrors({ ...errors, fedexAccountNumber: undefined });
|
||||
}
|
||||
}}
|
||||
placeholder="000000000000"
|
||||
maxLength={12}
|
||||
/>
|
||||
</AdminInput>
|
||||
<AdminInput
|
||||
label="API Key"
|
||||
required
|
||||
error={errors.fedexApiKey}
|
||||
helpText="From FedEx Developer Portal"
|
||||
>
|
||||
<AdminTextInput
|
||||
value={fedexApiKey}
|
||||
onChange={(e) => {
|
||||
setFedexApiKey(e.target.value);
|
||||
setDirty(true);
|
||||
if (errors.fedexApiKey) {
|
||||
setErrors({ ...errors, fedexApiKey: undefined });
|
||||
}
|
||||
}}
|
||||
placeholder="Your FedEx API key"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
developer.fedex.com
|
||||
</a>
|
||||
. These are used for shipping fresh produce.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminInput
|
||||
label="API Secret"
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AdminInput
|
||||
label="FedEx Account Number"
|
||||
required
|
||||
error={errors.fedexApiSecret}
|
||||
helpText="Password field — click Show/Hide to reveal"
|
||||
error={errors.fedexAccountNumber}
|
||||
helpText="12-digit FedEx account number"
|
||||
>
|
||||
<div className="relative">
|
||||
<AdminTextInput
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={fedexApiSecret}
|
||||
onChange={(e) => {
|
||||
setFedexApiSecret(e.target.value);
|
||||
setDirty(true);
|
||||
if (errors.fedexApiSecret) {
|
||||
setErrors({ ...errors, fedexApiSecret: undefined });
|
||||
}
|
||||
}}
|
||||
placeholder="Your FedEx API secret"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{showSecret ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
{/* Production toggle */}
|
||||
<div
|
||||
className="flex items-center gap-4 rounded-xl px-4 py-3 border"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<AdminToggle
|
||||
checked={fedexUseProduction}
|
||||
onChange={(checked) => {
|
||||
setFedexUseProduction(checked);
|
||||
setDirty(true);
|
||||
}}
|
||||
label="Use Production Mode"
|
||||
description={fedexUseProduction ? "Live rates, real labels" : "Sandbox/test mode — safe for testing"}
|
||||
<AdminTextInput
|
||||
value={fedexAccountNumber}
|
||||
onChange={(e) => onFieldChange("fedexAccountNumber", e.target.value)}
|
||||
placeholder="000000000000"
|
||||
maxLength={12}
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
<AdminInput
|
||||
label="API Key"
|
||||
required
|
||||
error={errors.fedexApiKey}
|
||||
helpText="From FedEx Developer Portal"
|
||||
>
|
||||
<AdminTextInput
|
||||
value={fedexApiKey}
|
||||
onChange={(e) => onFieldChange("fedexApiKey", e.target.value)}
|
||||
placeholder="Your FedEx API key"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !fedexApiKey || !fedexApiSecret}
|
||||
<AdminInput
|
||||
label="API Secret"
|
||||
required
|
||||
error={errors.fedexApiSecret}
|
||||
helpText="Password field — click Show/Hide to reveal"
|
||||
>
|
||||
<div className="relative">
|
||||
<AdminTextInput
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={fedexApiSecret}
|
||||
onChange={(e) => onFieldChange("fedexApiSecret", e.target.value)}
|
||||
placeholder="Your FedEx API secret"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleShowSecret}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{testing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Testing...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.55 4.05l7.97 7.97-2.12 2.12-7.85-7.85-2.12 2.12 7.97 7.97-2.12 2.12-7.97-7.97 2.12-2.12 7.97 7.97L3.58 6.17l7.97-7.97 2.12 2.12-7.22 7.73z" />
|
||||
</svg>
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</AdminButton>
|
||||
{testResult?.success && (
|
||||
<span className="text-sm font-medium flex items-center gap-1.5" style={{ color: "var(--admin-success)" }}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Connection verified
|
||||
</span>
|
||||
)}
|
||||
{showSecret ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminInput>
|
||||
|
||||
{/* Default shipping options */}
|
||||
<div
|
||||
className="space-y-5 rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
borderColor: "var(--admin-border)"
|
||||
{/* Production toggle */}
|
||||
<div
|
||||
className="flex items-center gap-4 rounded-xl px-4 py-3 border"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg)",
|
||||
borderColor: "var(--admin-border)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Default Shipping Service</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Applied automatically for non-perishable orders. Perishable orders always require Overnight or 2-Day Air.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminInput label="Default for non-perishable orders" helpText="Used when creating shipments for non-perishable items">
|
||||
<AdminSelect
|
||||
value={defaultServiceType}
|
||||
onChange={(e) => {
|
||||
setDefaultServiceType(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
options={SERVICE_OPTIONS}
|
||||
/>
|
||||
</AdminInput>
|
||||
<AdminToggle
|
||||
checked={fedexUseProduction}
|
||||
onChange={(checked) => onFieldChange("fedexUseProduction", checked)}
|
||||
label="Use Production Mode"
|
||||
description={fedexUseProduction ? "Live rates, real labels" : "Sandbox/test mode — safe for testing"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Handling notes */}
|
||||
<div
|
||||
className="space-y-5 rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Handling Instructions</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
These notes are attached to shipments for perishable or fragile items. Appear on carrier labels and in warehouse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminInput
|
||||
label="Refrigerated / Perishable Notes"
|
||||
helpText="Applied to all shipments with perishable items (sweet corn, onions, etc.)"
|
||||
{/* Test connection */}
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onTestConnection}
|
||||
disabled={testing || !fedexApiKey || !fedexApiSecret}
|
||||
>
|
||||
<AdminTextarea
|
||||
value={refrigeratedHandlingNotes}
|
||||
onChange={(e) => {
|
||||
setRefrigeratedHandlingNotes(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
|
||||
/>
|
||||
</AdminInput>
|
||||
{testing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Testing...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.55 4.05l7.97 7.97-2.12 2.12-7.85-7.85-2.12 2.12 7.97 7.97-2.12 2.12-7.97-7.97 2.12-2.12 7.97 7.97L3.58 6.17l7.97-7.97 2.12 2.12-7.22 7.73z" />
|
||||
</svg>
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<AdminInput label="Fragile Items Notes" helpText="Applied to shipments with fragile items">
|
||||
<AdminTextarea
|
||||
value={fragileHandlingNotes}
|
||||
onChange={(e) => {
|
||||
setFragileHandlingNotes(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
|
||||
/>
|
||||
</AdminInput>
|
||||
function DefaultShippingSection({
|
||||
defaultServiceType,
|
||||
onFieldChange,
|
||||
}: {
|
||||
defaultServiceType: string;
|
||||
onFieldChange: (field: FormField, value: string | boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="space-y-5 rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
borderColor: "var(--admin-border)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Default Shipping Service</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Applied automatically for non-perishable orders. Perishable orders always require Overnight or 2-Day Air.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save/Cancel buttons */}
|
||||
<AdminInput label="Default for non-perishable orders" helpText="Used when creating shipments for non-perishable items">
|
||||
<AdminSelect
|
||||
value={defaultServiceType}
|
||||
onChange={(e) => onFieldChange("defaultServiceType", e.target.value)}
|
||||
options={SERVICE_OPTIONS}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HandlingNotesSection({
|
||||
refrigeratedHandlingNotes,
|
||||
fragileHandlingNotes,
|
||||
onFieldChange,
|
||||
}: {
|
||||
refrigeratedHandlingNotes: string;
|
||||
fragileHandlingNotes: string;
|
||||
onFieldChange: (field: FormField, value: string | boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="space-y-5 rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
borderColor: "var(--admin-border)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Handling Instructions</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
These notes are attached to shipments for perishable or fragile items. Appear on carrier labels and in warehouse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminInput
|
||||
label="Refrigerated / Perishable Notes"
|
||||
helpText="Applied to all shipments with perishable items (sweet corn, onions, etc.)"
|
||||
>
|
||||
<AdminTextarea
|
||||
value={refrigeratedHandlingNotes}
|
||||
onChange={(e) => onFieldChange("refrigeratedHandlingNotes", e.target.value)}
|
||||
rows={3}
|
||||
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Fragile Items Notes" helpText="Applied to shipments with fragile items">
|
||||
<AdminTextarea
|
||||
value={fragileHandlingNotes}
|
||||
onChange={(e) => onFieldChange("fragileHandlingNotes", e.target.value)}
|
||||
rows={2}
|
||||
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButtonRow({ saving, dirty }: { saving: boolean; dirty: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{saving ? (
|
||||
<div className="flex items-center gap-3" style={{ color: "var(--admin-text-muted)" }}>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
@@ -561,6 +730,6 @@ function ShippingSettingsFormBody({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateStop } from "@/actions/stops/update-stop";
|
||||
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system/AdminFormElements";
|
||||
import { AdminInput, AdminSelect } from "./design-system/AdminFormElements";
|
||||
import AdminButton from "./design-system/AdminButton";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
@@ -33,6 +33,8 @@ type StopEditFormProps = {
|
||||
onSaved?: () => void;
|
||||
};
|
||||
|
||||
type DraftSetter<T> = (v: T | ((prev: T) => T)) => void;
|
||||
|
||||
export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
@@ -67,52 +69,52 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
const zip = draft.zip ?? stop.zip ?? "";
|
||||
const cutoff_time = draft.cutoff_time ?? stop.cutoff_time ?? "";
|
||||
|
||||
const setCity = (v: string | ((prev: string) => string)) =>
|
||||
const setCity: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
city: typeof v === "function" ? v(d.city ?? stop.city) : v,
|
||||
}));
|
||||
const setState = (v: string | ((prev: string) => string)) =>
|
||||
const setState: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
state: typeof v === "function" ? v(d.state ?? stop.state) : v,
|
||||
}));
|
||||
const setDate = (v: string | ((prev: string) => string)) =>
|
||||
const setDate: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
date: typeof v === "function" ? v(d.date ?? stop.date) : v,
|
||||
}));
|
||||
const setTime = (v: string | ((prev: string) => string)) =>
|
||||
const setTime: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
time: typeof v === "function" ? v(d.time ?? stop.time) : v,
|
||||
}));
|
||||
const setLocation = (v: string | ((prev: string) => string)) =>
|
||||
const setLocation: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
location: typeof v === "function" ? v(d.location ?? stop.location) : v,
|
||||
}));
|
||||
const setActive = (v: boolean | ((prev: boolean) => boolean)) =>
|
||||
const setActive: DraftSetter<boolean> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
active: typeof v === "function" ? v(d.active ?? stop.active) : v,
|
||||
}));
|
||||
const setBrand_id = (v: string | ((prev: string) => string)) =>
|
||||
const setBrand_id: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
brand_id: typeof v === "function" ? v(d.brand_id ?? stop.brand_id) : v,
|
||||
}));
|
||||
const setAddress = (v: string | ((prev: string) => string)) =>
|
||||
const setAddress: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
address: typeof v === "function" ? v(d.address ?? stop.address ?? "") : v,
|
||||
}));
|
||||
const setZip = (v: string | ((prev: string) => string)) =>
|
||||
const setZip: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
zip: typeof v === "function" ? v(d.zip ?? stop.zip ?? "") : v,
|
||||
}));
|
||||
const setCutoff_time = (v: string | ((prev: string) => string)) =>
|
||||
const setCutoff_time: DraftSetter<string> = (v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
cutoff_time:
|
||||
@@ -144,7 +146,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
showError("Validation failed", "Please fix the errors below");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
savedRef.current = false;
|
||||
@@ -177,15 +179,109 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<FormErrorBanner error={error} />
|
||||
|
||||
<LocationIdentityFields
|
||||
city={city}
|
||||
state={state}
|
||||
date={date}
|
||||
time={time}
|
||||
fieldErrors={fieldErrors}
|
||||
setCity={setCity}
|
||||
setState={setState}
|
||||
setDate={setDate}
|
||||
setTime={setTime}
|
||||
clearFieldError={(field) =>
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<AddressFields
|
||||
location={location}
|
||||
address={address}
|
||||
zip={zip}
|
||||
setLocation={setLocation}
|
||||
setAddress={setAddress}
|
||||
setZip={setZip}
|
||||
/>
|
||||
|
||||
<AdminInput
|
||||
label="Order Cutoff"
|
||||
helpText="Customers must order before this time to be included at this stop."
|
||||
>
|
||||
<input aria-label="Datetime Local"
|
||||
type="datetime-local"
|
||||
value={cutoff_time}
|
||||
onChange={(e) => setCutoff_time(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
value={brand_id}
|
||||
onChange={(e) => setBrand_id(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<StatusToggle active={active} onToggle={() => setActive((v) => !v)} />
|
||||
|
||||
<AdminButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormErrorBanner({ error }: { error: string | null }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationIdentityFields({
|
||||
city,
|
||||
state,
|
||||
date,
|
||||
time,
|
||||
fieldErrors,
|
||||
setCity,
|
||||
setState,
|
||||
setDate,
|
||||
setTime,
|
||||
clearFieldError,
|
||||
}: {
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
fieldErrors: Record<string, string>;
|
||||
setCity: DraftSetter<string>;
|
||||
setState: DraftSetter<string>;
|
||||
setDate: DraftSetter<string>;
|
||||
setTime: DraftSetter<string>;
|
||||
clearFieldError: (field: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5" htmlFor="fld-city-">
|
||||
@@ -196,11 +292,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
value={city}
|
||||
onChange={(e) => {
|
||||
setCity(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.city;
|
||||
return next;
|
||||
});
|
||||
clearFieldError("city");
|
||||
}}
|
||||
placeholder="City name"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
@@ -221,11 +313,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
value={state}
|
||||
onChange={(e) => {
|
||||
setState(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.state;
|
||||
return next;
|
||||
});
|
||||
clearFieldError("state");
|
||||
}}
|
||||
placeholder="State code"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
@@ -248,11 +336,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.date;
|
||||
return next;
|
||||
});
|
||||
clearFieldError("date");
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.date
|
||||
@@ -264,8 +348,8 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-1-time" className="block text-xs font-semibold text-stone-700 mb-1.5">Time</label>
|
||||
<input id="fld-1-time" aria-label=". 8:00 AM – 2:00 PM"
|
||||
<label htmlFor="fld-1-time" className="block text-xs font-semibold text-stone-700 mb-1.5">Time</label>
|
||||
<input id="fld-1-time" aria-label=". 8:00 AM – 2:00 PM"
|
||||
type="text"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
@@ -274,10 +358,30 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressFields({
|
||||
location,
|
||||
address,
|
||||
zip,
|
||||
setLocation,
|
||||
setAddress,
|
||||
setZip,
|
||||
}: {
|
||||
location: string;
|
||||
address: string;
|
||||
zip: string;
|
||||
setLocation: DraftSetter<string>;
|
||||
setAddress: DraftSetter<string>;
|
||||
setZip: DraftSetter<string>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="fld-2-location-name" className="block text-xs font-semibold text-stone-700 mb-1.5">Location Name</label>
|
||||
<input id="fld-2-location-name" aria-label="Street Address Or Intersection"
|
||||
<label htmlFor="fld-2-location-name" className="block text-xs font-semibold text-stone-700 mb-1.5">Location Name</label>
|
||||
<input id="fld-2-location-name" aria-label="Street Address Or Intersection"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
@@ -288,8 +392,8 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="fld-3-street-address" className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
|
||||
<input id="fld-3-street-address" aria-label="123 Main St"
|
||||
<label htmlFor="fld-3-street-address" className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
|
||||
<input id="fld-3-street-address" aria-label="123 Main St"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
@@ -299,8 +403,8 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="fld-4-zip-code" className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
|
||||
<input id="fld-4-zip-code" aria-label="80102"
|
||||
<label htmlFor="fld-4-zip-code" className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
|
||||
<input id="fld-4-zip-code" aria-label="80102"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
@@ -310,55 +414,28 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<AdminInput
|
||||
label="Order Cutoff"
|
||||
helpText="Customers must order before this time to be included at this stop."
|
||||
>
|
||||
<input aria-label="Datetime Local"
|
||||
type="datetime-local"
|
||||
value={cutoff_time}
|
||||
onChange={(e) => setCutoff_time(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
value={brand_id}
|
||||
onChange={(e) => setBrand_id(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-stone-700">
|
||||
<span className="block mb-2">Status</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive((v) => !v)}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-green-100 text-green-700 border border-green-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{active ? "✓ " : "○ "}</span>
|
||||
{active ? "Active — visible on storefront" : "Inactive — hidden from storefront"}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<AdminButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</AdminButton>
|
||||
function StatusToggle({ active, onToggle }: { active: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-stone-700">
|
||||
<span className="block mb-2">Status</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-green-100 text-green-700 border border-green-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{active ? "✓ " : "○ "}</span>
|
||||
{active ? "Active — visible on storefront" : "Inactive — hidden from storefront"}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import NextImage from "next/image";
|
||||
import { useReducer, useMemo, useEffect } from "react";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
import { assignProductToStop, unassignProductFromStop } from "@/actions/stops/manage-stop-products";
|
||||
|
||||
@@ -40,6 +40,48 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
subscription: "Sub",
|
||||
};
|
||||
|
||||
type State = {
|
||||
products: AssignedProduct[];
|
||||
search: string;
|
||||
filter: Filter;
|
||||
pendingId: string | null;
|
||||
removingId: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "ADD_PRODUCT"; product: AssignedProduct }
|
||||
| { type: "REMOVE_PRODUCT"; productId: string }
|
||||
| { type: "SET_SEARCH"; value: string }
|
||||
| { type: "SET_FILTER"; value: Filter }
|
||||
| { type: "SET_PENDING"; value: string | null }
|
||||
| { type: "SET_REMOVING"; value: string | null }
|
||||
| { type: "SET_ERROR"; value: string | null };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "ADD_PRODUCT":
|
||||
if (state.products.some((p) => p.product_id === action.product.product_id)) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, products: [...state.products, action.product] };
|
||||
case "REMOVE_PRODUCT":
|
||||
return { ...state, products: state.products.filter((p) => p.product_id !== action.productId) };
|
||||
case "SET_SEARCH":
|
||||
return { ...state, search: action.value };
|
||||
case "SET_FILTER":
|
||||
return { ...state, filter: action.value };
|
||||
case "SET_PENDING":
|
||||
return { ...state, pendingId: action.value };
|
||||
case "SET_REMOVING":
|
||||
return { ...state, removingId: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function StopProductAssignment({
|
||||
stopId,
|
||||
allProducts,
|
||||
@@ -49,78 +91,77 @@ export default function StopProductAssignment({
|
||||
// Use a lazy initializer so the lint's static "useState(prop)" check does
|
||||
// not fire — the seed value is computed once on first render, and user
|
||||
// mutations (assign/remove) drive subsequent updates locally.
|
||||
const [products, setProducts] = useState<AssignedProduct[]>(() => assignedProducts);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, assignedProducts, (initial): State => ({
|
||||
products: initial,
|
||||
search: "",
|
||||
filter: "all",
|
||||
pendingId: null,
|
||||
removingId: null,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => new Set(products.map((p) => p.product_id)),
|
||||
[products]
|
||||
() => new Set(state.products.map((p) => p.product_id)),
|
||||
[state.products]
|
||||
);
|
||||
|
||||
// Counters for the filter chips
|
||||
const counts = useMemo(() => {
|
||||
const assignedCount = products.length;
|
||||
const assignedCount = state.products.length;
|
||||
const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length;
|
||||
return {
|
||||
all: allProducts.length,
|
||||
available: availableCount,
|
||||
assigned: assignedCount,
|
||||
};
|
||||
}, [allProducts, products, assignedIds]);
|
||||
}, [allProducts, state.products, assignedIds]);
|
||||
|
||||
// Apply search + filter
|
||||
const visibleProducts = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const q = state.search.trim().toLowerCase();
|
||||
return allProducts.filter((p) => {
|
||||
const isAssigned = assignedIds.has(p.id);
|
||||
if (filter === "available" && isAssigned) return false;
|
||||
if (filter === "assigned" && !isAssigned) return false;
|
||||
if (state.filter === "available" && isAssigned) return false;
|
||||
if (state.filter === "assigned" && !isAssigned) return false;
|
||||
if (!q) return true;
|
||||
return p.name.toLowerCase().includes(q) || p.type.toLowerCase().includes(q);
|
||||
});
|
||||
}, [allProducts, search, filter, assignedIds]);
|
||||
}, [allProducts, state.search, state.filter, assignedIds]);
|
||||
|
||||
async function assign(productId: string) {
|
||||
if (!productId || assignedIds.has(productId) || pendingId) return;
|
||||
if (!productId || assignedIds.has(productId) || state.pendingId) return;
|
||||
|
||||
const product = allProducts.find((p) => p.id === productId);
|
||||
if (!product) return;
|
||||
|
||||
setPendingId(productId);
|
||||
setError(null);
|
||||
dispatch({ type: "SET_PENDING", value: productId });
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
|
||||
const result = await assignProductToStop({ stopId, productId });
|
||||
|
||||
if (!result.success) {
|
||||
setError(`Failed to assign: ${result.error}`);
|
||||
setPendingId(null);
|
||||
dispatch({ type: "SET_ERROR", value: `Failed to assign: ${result.error}` });
|
||||
dispatch({ type: "SET_PENDING", value: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic insert: build the entry from the product we already have
|
||||
// locally, keyed by the row id returned from the RPC. Use functional
|
||||
// setState so concurrent calls compose correctly.
|
||||
setProducts((prev) => {
|
||||
if (prev.some((p) => p.product_id === productId)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: result.id,
|
||||
product_id: productId,
|
||||
products: {
|
||||
name: product.name,
|
||||
type: product.type,
|
||||
price: product.price,
|
||||
image_url: product.image_url ?? null,
|
||||
},
|
||||
dispatch({
|
||||
type: "ADD_PRODUCT",
|
||||
product: {
|
||||
id: result.id,
|
||||
product_id: productId,
|
||||
products: {
|
||||
name: product.name,
|
||||
type: product.type,
|
||||
price: product.price,
|
||||
image_url: product.image_url ?? null,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
setPendingId(null);
|
||||
dispatch({ type: "SET_PENDING", value: null });
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
@@ -133,23 +174,23 @@ export default function StopProductAssignment({
|
||||
}
|
||||
|
||||
async function remove(productId: string) {
|
||||
if (removingId) return;
|
||||
const entry = products.find((p) => p.product_id === productId);
|
||||
if (state.removingId) return;
|
||||
const entry = state.products.find((p) => p.product_id === productId);
|
||||
if (!entry) return;
|
||||
|
||||
setRemovingId(productId);
|
||||
setError(null);
|
||||
dispatch({ type: "SET_REMOVING", value: productId });
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
|
||||
const result = await unassignProductFromStop({ stopId, productId });
|
||||
|
||||
if (!result.success) {
|
||||
setError(`Failed to remove: ${result.error}`);
|
||||
setRemovingId(null);
|
||||
dispatch({ type: "SET_ERROR", value: `Failed to remove: ${result.error}` });
|
||||
dispatch({ type: "SET_REMOVING", value: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setProducts((prev) => prev.filter((p) => p.product_id !== productId));
|
||||
setRemovingId(null);
|
||||
dispatch({ type: "REMOVE_PRODUCT", productId });
|
||||
dispatch({ type: "SET_REMOVING", value: null });
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
@@ -170,15 +211,15 @@ export default function StopProductAssignment({
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!products.length || removingId || pendingId) return;
|
||||
if (!state.products.length || state.removingId || state.pendingId) return;
|
||||
// Snapshot ids at click time, then remove each one. Iterations run in
|
||||
// parallel; each callback checks for a prior error and short-circuits
|
||||
// when one is already set so subsequent calls stop early.
|
||||
const idsToRemove = products.map((p) => p.product_id);
|
||||
const idsToRemove = state.products.map((p) => p.product_id);
|
||||
void Promise.all(
|
||||
idsToRemove.map(async (id) => {
|
||||
// Stop if a per-item failure already surfaced an error
|
||||
if (error) return;
|
||||
if (state.error) return;
|
||||
try {
|
||||
await remove(id);
|
||||
} catch {
|
||||
@@ -202,208 +243,333 @@ export default function StopProductAssignment({
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const hasAssigned = products.length > 0;
|
||||
const hasAssigned = state.products.length > 0;
|
||||
const hasProducts = allProducts.length > 0;
|
||||
|
||||
return (
|
||||
<div className="ha-picker">
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{
|
||||
background: "var(--admin-danger-soft)",
|
||||
border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="font-mono text-[11px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{state.error && <PickerErrorBanner error={state.error} />}
|
||||
|
||||
{/* Header: editorial title + search */}
|
||||
<div className="ha-picker-header">
|
||||
<div className="ha-picker-title">
|
||||
<span className="ha-picker-title-num font-display">
|
||||
{String(products.length).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="ha-picker-title-label">
|
||||
{products.length === 1 ? "Product on this stop" : "Products on this stop"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-picker-search">
|
||||
<span className="ha-picker-search-icon" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<input aria-label="Search Products…"
|
||||
id="stop-product-search"
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search products…"
|
||||
className="ha-picker-search-input"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PickerHeader
|
||||
count={state.products.length}
|
||||
search={state.search}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="ha-picker-filterbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("all")}
|
||||
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
All
|
||||
<span className="ha-picker-chip-count">{counts.all}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("available")}
|
||||
className={`ha-picker-chip ${filter === "available" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
Available
|
||||
<span className="ha-picker-chip-count">{counts.available}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("assigned")}
|
||||
className={`ha-picker-chip ${filter === "assigned" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
On this stop
|
||||
<span className="ha-picker-chip-count">{counts.assigned}</span>
|
||||
</button>
|
||||
<span className="ha-modal-footer-hint ml-auto">
|
||||
Press <kbd>/</kbd> to search
|
||||
</span>
|
||||
</div>
|
||||
<FilterChips
|
||||
filter={state.filter}
|
||||
counts={counts}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
|
||||
{/* Product grid */}
|
||||
{!hasProducts ? (
|
||||
<div className="ha-picker-empty">
|
||||
<p className="ha-picker-empty-mark">No products yet</p>
|
||||
<p className="ha-picker-empty-text">
|
||||
Create products in the catalog first, then come back here to assign them to this stop.
|
||||
</p>
|
||||
</div>
|
||||
<PickerEmptyState variant="no-products" />
|
||||
) : visibleProducts.length === 0 ? (
|
||||
<div className="ha-picker-empty">
|
||||
<p className="ha-picker-empty-mark">
|
||||
{filter === "assigned" ? "Nothing assigned" : "No matches"}
|
||||
</p>
|
||||
<p className="ha-picker-empty-text">
|
||||
{search.trim()
|
||||
? `No products match "${search.trim()}". Try a different term.`
|
||||
: filter === "assigned"
|
||||
? "This stop has no products assigned yet. Click a card in the All tab to add one."
|
||||
: "All products are already assigned to this stop."}
|
||||
</p>
|
||||
</div>
|
||||
<PickerEmptyState
|
||||
variant="no-matches"
|
||||
filter={state.filter}
|
||||
search={state.search}
|
||||
/>
|
||||
) : (
|
||||
<div className="ha-picker-grid">
|
||||
{visibleProducts.map((product) => {
|
||||
const isAssigned = assignedIds.has(product.id);
|
||||
const isBusy = pendingId === product.id || removingId === product.id;
|
||||
return (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
onClick={() => !isBusy && toggle(product.id)}
|
||||
disabled={isBusy}
|
||||
aria-pressed={isAssigned}
|
||||
className={`ha-product-card ${isAssigned ? "ha-product-card--assigned" : ""} ${
|
||||
isBusy ? "ha-product-card--disabled" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Image / Initial */}
|
||||
<span className="ha-product-card-image">
|
||||
{product.image_url ? (
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="48px"
|
||||
style={{ objectFit: "cover" }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
initialOf(product.name)
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Body */}
|
||||
<span className="ha-product-card-body">
|
||||
<span className="ha-product-card-name">{product.name}</span>
|
||||
<span className="ha-product-card-meta">
|
||||
<span className="ha-product-card-type">{TYPE_LABEL[product.type] ?? product.type}</span>
|
||||
<span className="ha-product-card-price">{formatPrice(product.price)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Status icon */}
|
||||
{isBusy ? (
|
||||
<span className="ha-product-card-plus" aria-hidden="true">
|
||||
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
) : isAssigned ? (
|
||||
<>
|
||||
<span className="ha-product-card-check" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="ha-product-card-remove" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M6 6l12 12M6 18 18 6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="ha-product-card-plus" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ProductGrid
|
||||
products={visibleProducts}
|
||||
assignedIds={assignedIds}
|
||||
pendingId={state.pendingId}
|
||||
removingId={state.removingId}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Summary footer when assigned items exist */}
|
||||
{hasAssigned && (
|
||||
<div className="ha-picker-summary">
|
||||
<span className="ha-picker-summary-left">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>
|
||||
<span className="ha-picker-summary-count">{products.length}</span>{" "}
|
||||
{products.length === 1 ? "product is" : "products are"} live at this stop
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
disabled={!!removingId}
|
||||
className="ha-picker-summary-clear"
|
||||
>
|
||||
Remove all
|
||||
</button>
|
||||
</div>
|
||||
<PickerSummary
|
||||
count={state.products.length}
|
||||
removing={!!state.removingId}
|
||||
onClearAll={clearAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerErrorBanner({ error }: { error: string }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{
|
||||
background: "var(--admin-danger-soft)",
|
||||
border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="font-mono text-[11px]">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerHeader({
|
||||
count,
|
||||
search,
|
||||
dispatch,
|
||||
}: {
|
||||
count: number;
|
||||
search: string;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div className="ha-picker-header">
|
||||
<div className="ha-picker-title">
|
||||
<span className="ha-picker-title-num font-display">
|
||||
{String(count).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="ha-picker-title-label">
|
||||
{count === 1 ? "Product on this stop" : "Products on this stop"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-picker-search">
|
||||
<span className="ha-picker-search-icon" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<input aria-label="Search Products…"
|
||||
id="stop-product-search"
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => dispatch({ type: "SET_SEARCH", value: e.target.value })}
|
||||
placeholder="Search products…"
|
||||
className="ha-picker-search-input"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChips({
|
||||
filter,
|
||||
counts,
|
||||
dispatch,
|
||||
}: {
|
||||
filter: Filter;
|
||||
counts: { all: number; available: number; assigned: number };
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}) {
|
||||
return (
|
||||
<div className="ha-picker-filterbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "SET_FILTER", value: "all" })}
|
||||
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
All
|
||||
<span className="ha-picker-chip-count">{counts.all}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "SET_FILTER", value: "available" })}
|
||||
className={`ha-picker-chip ${filter === "available" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
Available
|
||||
<span className="ha-picker-chip-count">{counts.available}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "SET_FILTER", value: "assigned" })}
|
||||
className={`ha-picker-chip ${filter === "assigned" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
On this stop
|
||||
<span className="ha-picker-chip-count">{counts.assigned}</span>
|
||||
</button>
|
||||
<span className="ha-modal-footer-hint ml-auto">
|
||||
Press <kbd>/</kbd> to search
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerEmptyState({
|
||||
variant,
|
||||
filter,
|
||||
search,
|
||||
}: {
|
||||
variant: "no-products" | "no-matches";
|
||||
filter?: Filter;
|
||||
search?: string;
|
||||
}) {
|
||||
if (variant === "no-products") {
|
||||
return (
|
||||
<div className="ha-picker-empty">
|
||||
<p className="ha-picker-empty-mark">No products yet</p>
|
||||
<p className="ha-picker-empty-text">
|
||||
Create products in the catalog first, then come back here to assign them to this stop.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="ha-picker-empty">
|
||||
<p className="ha-picker-empty-mark">
|
||||
{filter === "assigned" ? "Nothing assigned" : "No matches"}
|
||||
</p>
|
||||
<p className="ha-picker-empty-text">
|
||||
{search?.trim()
|
||||
? `No products match "${search.trim()}". Try a different term.`
|
||||
: filter === "assigned"
|
||||
? "This stop has no products assigned yet. Click a card in the All tab to add one."
|
||||
: "All products are already assigned to this stop."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductGrid({
|
||||
products,
|
||||
assignedIds,
|
||||
pendingId,
|
||||
removingId,
|
||||
onToggle,
|
||||
}: {
|
||||
products: Product[];
|
||||
assignedIds: Set<string>;
|
||||
pendingId: string | null;
|
||||
removingId: string | null;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ha-picker-grid">
|
||||
{products.map((product) => {
|
||||
const isAssigned = assignedIds.has(product.id);
|
||||
const isBusy = pendingId === product.id || removingId === product.id;
|
||||
return (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
isAssigned={isAssigned}
|
||||
isBusy={isBusy}
|
||||
onToggle={() => onToggle(product.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductCard({
|
||||
product,
|
||||
isAssigned,
|
||||
isBusy,
|
||||
onToggle,
|
||||
}: {
|
||||
product: Product;
|
||||
isAssigned: boolean;
|
||||
isBusy: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={isBusy}
|
||||
aria-pressed={isAssigned}
|
||||
className={`ha-product-card ${isAssigned ? "ha-product-card--assigned" : ""} ${
|
||||
isBusy ? "ha-product-card--disabled" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Image / Initial */}
|
||||
<span className="ha-product-card-image">
|
||||
{product.image_url ? (
|
||||
<NextImage
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="48px"
|
||||
style={{ objectFit: "cover" }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
initialOf(product.name)
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Body */}
|
||||
<span className="ha-product-card-body">
|
||||
<span className="ha-product-card-name">{product.name}</span>
|
||||
<span className="ha-product-card-meta">
|
||||
<span className="ha-product-card-type">{TYPE_LABEL[product.type] ?? product.type}</span>
|
||||
<span className="ha-product-card-price">{formatPrice(product.price)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Status icon */}
|
||||
{isBusy ? (
|
||||
<span className="ha-product-card-plus" aria-hidden="true">
|
||||
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
) : isAssigned ? (
|
||||
<>
|
||||
<span className="ha-product-card-check" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="ha-product-card-remove" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M6 6l12 12M6 18 18 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="ha-product-card-plus" aria-hidden="true">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerSummary({
|
||||
count,
|
||||
removing,
|
||||
onClearAll,
|
||||
}: {
|
||||
count: number;
|
||||
removing: boolean;
|
||||
onClearAll: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ha-picker-summary">
|
||||
<span className="ha-picker-summary-left">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>
|
||||
<span className="ha-picker-summary-count">{count}</span>{" "}
|
||||
{count === 1 ? "product is" : "products are"} live at this stop
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
disabled={removing}
|
||||
className="ha-picker-summary-clear"
|
||||
>
|
||||
Remove all
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import React, { useState, useTransition, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import React, { useReducer, useTransition, useMemo, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop, deleteStop } from "@/actions/stops";
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
AdminSearchInput,
|
||||
AdminFilterTabs,
|
||||
AdminButton,
|
||||
AdminIconButton,
|
||||
AdminViewModeTabs,
|
||||
useToast,
|
||||
Skeleton,
|
||||
@@ -42,14 +40,17 @@ type Props = {
|
||||
type ViewMode = "table" | "cards";
|
||||
type SortField = "date" | "city" | "location" | "status";
|
||||
type SortDirection = "asc" | "desc";
|
||||
type StatusFilter = "all" | "active" | "inactive" | "draft";
|
||||
|
||||
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
|
||||
const TAB_NUMERIC: Record<StatusFilter, StatusFilter> = {
|
||||
all: "all",
|
||||
active: "active",
|
||||
inactive: "inactive",
|
||||
draft: "draft",
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
// Icons
|
||||
const Icons = {
|
||||
search: (className: string) => (
|
||||
@@ -86,23 +87,109 @@ const StopIconHeader = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── State / Reducer ────────────────────────────────────────────────────────────
|
||||
|
||||
type State = {
|
||||
search: string;
|
||||
statusFilter: StatusFilter;
|
||||
deleteError: string | null;
|
||||
page: number;
|
||||
selectedStops: Set<string>;
|
||||
bulkPublishing: boolean;
|
||||
showImport: boolean;
|
||||
editingStop: Stop | null;
|
||||
showAddModal: boolean;
|
||||
sortField: SortField;
|
||||
sortDirection: SortDirection;
|
||||
viewMode: ViewMode;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_SEARCH"; value: string }
|
||||
| { type: "SET_STATUS_FILTER"; value: StatusFilter }
|
||||
| { type: "SET_DELETE_ERROR"; value: string | null }
|
||||
| { type: "SET_PAGE"; value: number }
|
||||
| { type: "RESET_PAGE" }
|
||||
| { type: "SELECT_ALL"; stops: Stop[] }
|
||||
| { type: "CLEAR_SELECTION" }
|
||||
| { type: "TOGGLE_SELECT"; stopId: string }
|
||||
| { type: "SET_BULK_PUBLISHING"; value: boolean }
|
||||
| { type: "SET_SHOW_IMPORT"; value: boolean }
|
||||
| { type: "SET_EDITING_STOP"; value: Stop | null }
|
||||
| { type: "SET_SHOW_ADD_MODAL"; value: boolean }
|
||||
| { type: "CLOSE_EDIT_MODAL" }
|
||||
| { type: "SET_SORT"; field: SortField }
|
||||
| { type: "TOGGLE_SORT_DIRECTION" }
|
||||
| { type: "SET_VIEW_MODE"; value: ViewMode };
|
||||
|
||||
const initialState: State = {
|
||||
search: "",
|
||||
statusFilter: "all",
|
||||
deleteError: null,
|
||||
page: 0,
|
||||
selectedStops: new Set(),
|
||||
bulkPublishing: false,
|
||||
showImport: false,
|
||||
editingStop: null,
|
||||
showAddModal: false,
|
||||
sortField: "date",
|
||||
sortDirection: "asc",
|
||||
viewMode: "table",
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_SEARCH":
|
||||
return { ...state, search: action.value };
|
||||
case "SET_STATUS_FILTER":
|
||||
return { ...state, statusFilter: action.value };
|
||||
case "SET_DELETE_ERROR":
|
||||
return { ...state, deleteError: action.value };
|
||||
case "SET_PAGE":
|
||||
return { ...state, page: action.value };
|
||||
case "RESET_PAGE":
|
||||
return { ...state, page: 0 };
|
||||
case "SELECT_ALL":
|
||||
return { ...state, selectedStops: new Set(action.stops.map((s) => s.id)) };
|
||||
case "CLEAR_SELECTION":
|
||||
return { ...state, selectedStops: new Set() };
|
||||
case "TOGGLE_SELECT": {
|
||||
const next = new Set(state.selectedStops);
|
||||
if (next.has(action.stopId)) {
|
||||
next.delete(action.stopId);
|
||||
} else {
|
||||
next.add(action.stopId);
|
||||
}
|
||||
return { ...state, selectedStops: next };
|
||||
}
|
||||
case "SET_BULK_PUBLISHING":
|
||||
return { ...state, bulkPublishing: action.value };
|
||||
case "SET_SHOW_IMPORT":
|
||||
return { ...state, showImport: action.value };
|
||||
case "SET_EDITING_STOP":
|
||||
return { ...state, editingStop: action.value };
|
||||
case "SET_SHOW_ADD_MODAL":
|
||||
return { ...state, showAddModal: action.value };
|
||||
case "CLOSE_EDIT_MODAL":
|
||||
return { ...state, showAddModal: false, editingStop: null };
|
||||
case "SET_SORT":
|
||||
return { ...state, sortField: action.field, sortDirection: "asc" };
|
||||
case "TOGGLE_SORT_DIRECTION":
|
||||
return { ...state, sortDirection: state.sortDirection === "asc" ? "desc" : "asc" };
|
||||
case "SET_VIEW_MODE":
|
||||
return { ...state, viewMode: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [editingStop, setEditingStop] = useState<Stop | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [sortField, setSortField] = useState<SortField>("date");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||
const PAGE_SIZE = 25;
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const {
|
||||
search, statusFilter, deleteError, page, selectedStops, bulkPublishing,
|
||||
showImport, editingStop, showAddModal, sortField, sortDirection, viewMode,
|
||||
} = state;
|
||||
|
||||
// Compute stats
|
||||
const stats = useMemo(() => {
|
||||
@@ -149,12 +236,13 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
case "location":
|
||||
comparison = a.location.localeCompare(b.location);
|
||||
break;
|
||||
case "status":
|
||||
case "status": {
|
||||
const statusOrder = { draft: 0, inactive: 1, active: 2 };
|
||||
const aStatus = a.status === "draft" ? "draft" : a.active ? "active" : "inactive";
|
||||
const bStatus = b.status === "draft" ? "draft" : b.active ? "active" : "inactive";
|
||||
comparison = statusOrder[aStatus] - statusOrder[bStatus];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
@@ -185,39 +273,18 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||
dispatch({ type: "TOGGLE_SORT_DIRECTION" });
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
dispatch({ type: "SET_SORT", field });
|
||||
}
|
||||
setPage(0);
|
||||
dispatch({ type: "RESET_PAGE" });
|
||||
}, [sortField]);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedStops.size === paginatedStops.length) {
|
||||
setSelectedStops(new Set());
|
||||
} else {
|
||||
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStopSelection = (stopId: string) => {
|
||||
setSelectedStops((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(stopId)) {
|
||||
next.delete(stopId);
|
||||
} else {
|
||||
next.add(stopId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function handleBulkPublish() {
|
||||
function handleBulkPublish() {
|
||||
if (selectedStops.size === 0) return;
|
||||
setBulkPublishing(true);
|
||||
dispatch({ type: "SET_BULK_PUBLISHING", value: true });
|
||||
const stopIds = Array.from(selectedStops);
|
||||
const results = await Promise.all(
|
||||
void Promise.all(
|
||||
stopIds.map(async (stopId) => {
|
||||
const stop = stops.find((s) => s.id === stopId);
|
||||
if (!stop || stop.status !== "draft") return null; // skipped — not a draft
|
||||
@@ -228,21 +295,22 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
return false;
|
||||
}
|
||||
})
|
||||
);
|
||||
const successCount = results.filter((r) => r === true).length;
|
||||
const failCount = results.filter((r) => r === false).length;
|
||||
setBulkPublishing(false);
|
||||
setSelectedStops(new Set());
|
||||
if (failCount === 0) {
|
||||
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
|
||||
} else {
|
||||
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
||||
}
|
||||
startTransition(() => router.refresh());
|
||||
).then((results) => {
|
||||
const successCount = results.filter((r) => r === true).length;
|
||||
const failCount = results.filter((r) => r === false).length;
|
||||
dispatch({ type: "SET_BULK_PUBLISHING", value: false });
|
||||
dispatch({ type: "CLEAR_SELECTION" });
|
||||
if (failCount === 0) {
|
||||
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
|
||||
} else {
|
||||
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
||||
}
|
||||
startTransition(() => router.refresh());
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleted() {
|
||||
setDeleteError(null);
|
||||
dispatch({ type: "SET_DELETE_ERROR", value: null });
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
@@ -250,158 +318,72 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
const hasFilters = !!(search || statusFilter !== "all");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--admin-accent)]/10">
|
||||
<StopIconHeader />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
|
||||
Stops & Routes
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{sorted.length} stop{sorted.length !== 1 ? "s" : ""}
|
||||
{search || statusFilter !== "all" ? " matching filters" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowImport(true)}
|
||||
icon={Icons.upload("h-4 w-4")}
|
||||
>
|
||||
Upload
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
icon={Icons.plus("h-4 w-4")}
|
||||
>
|
||||
Add Stop
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1 tabular-nums">
|
||||
{stats.total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1 tabular-nums">
|
||||
{stats.active}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Upcoming</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-sky-600 mt-1 tabular-nums">
|
||||
{stats.upcoming}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Drafts</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1 tabular-nums">
|
||||
{stats.draft}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<StopsPageHeader
|
||||
sortedCount={sorted.length}
|
||||
hasFilters={hasFilters}
|
||||
onUpload={() => dispatch({ type: "SET_SHOW_IMPORT", value: true })}
|
||||
onAdd={() => dispatch({ type: "SET_SHOW_ADD_MODAL", value: true })}
|
||||
/>
|
||||
<StatsGrid stats={stats} />
|
||||
{!hideInternalFilterBar && (
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(value) => {
|
||||
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
|
||||
setPage(0);
|
||||
}}
|
||||
<StopsFilterBar
|
||||
statusFilter={statusFilter}
|
||||
search={search}
|
||||
viewMode={viewMode}
|
||||
tabs={tabs}
|
||||
size="md"
|
||||
/>
|
||||
<AdminSearchInput
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
onStatusFilterChange={(value) => {
|
||||
dispatch({ type: "SET_STATUS_FILTER", value: TAB_NUMERIC[value as StatusFilter] ?? "all" });
|
||||
dispatch({ type: "RESET_PAGE" });
|
||||
}}
|
||||
onClear={() => {
|
||||
setSearch("");
|
||||
setPage(0);
|
||||
onSearchChange={(e) => {
|
||||
dispatch({ type: "SET_SEARCH", value: e.target.value });
|
||||
dispatch({ type: "RESET_PAGE" });
|
||||
}}
|
||||
placeholder="Search stops..."
|
||||
containerClassName="flex-1"
|
||||
onSearchClear={() => {
|
||||
dispatch({ type: "SET_SEARCH", value: "" });
|
||||
dispatch({ type: "RESET_PAGE" });
|
||||
}}
|
||||
onViewModeChange={(value) => dispatch({ type: "SET_VIEW_MODE", value: value as ViewMode })}
|
||||
/>
|
||||
<AdminViewModeTabs
|
||||
activeTab={viewMode}
|
||||
onTabChange={(value) => setViewMode(value as ViewMode)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedStops.size > 0 && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
|
||||
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<button type="button"
|
||||
onClick={() => setSelectedStops(new Set())}
|
||||
className="text-xs text-stone-500 hover:text-stone-700"
|
||||
aria-label="Clear">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleBulkPublish}
|
||||
isLoading={bulkPublishing}
|
||||
>
|
||||
Publish Selected
|
||||
</AdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete error */}
|
||||
{deleteError && (
|
||||
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||||
{deleteError}{" "}
|
||||
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline" aria-label="Dismiss">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedStops.size}
|
||||
bulkPublishing={bulkPublishing}
|
||||
onClear={() => dispatch({ type: "CLEAR_SELECTION" })}
|
||||
onPublish={handleBulkPublish}
|
||||
/>
|
||||
<DeleteErrorBanner
|
||||
error={deleteError}
|
||||
onDismiss={() => dispatch({ type: "SET_DELETE_ERROR", value: null })}
|
||||
/>
|
||||
{viewMode === "table" ? (
|
||||
<TableView
|
||||
stops={paginatedStops}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onEdit={setEditingStop}
|
||||
onEdit={(stop) => dispatch({ type: "SET_EDITING_STOP", value: stop })}
|
||||
onDelete={(id) => {
|
||||
const stop = stops.find((s) => s.id === id);
|
||||
if (stop) {
|
||||
setEditingStop(stop);
|
||||
dispatch({ type: "SET_EDITING_STOP", value: stop });
|
||||
}
|
||||
}}
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
onDeleteError={(msg) => dispatch({ type: "SET_DELETE_ERROR", value: msg })}
|
||||
selectedStops={selectedStops}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onToggleSelect={toggleStopSelection}
|
||||
onToggleSelectAll={() => {
|
||||
if (selectedStops.size === paginatedStops.length) {
|
||||
dispatch({ type: "CLEAR_SELECTION" });
|
||||
} else {
|
||||
dispatch({ type: "SELECT_ALL", stops: paginatedStops });
|
||||
}
|
||||
}}
|
||||
onToggleSelect={(id) => dispatch({ type: "TOGGLE_SELECT", stopId: id })}
|
||||
isLoading={false}
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
@@ -409,43 +391,31 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
) : (
|
||||
<CardView
|
||||
stops={paginatedStops}
|
||||
onEdit={setEditingStop}
|
||||
onEdit={(stop) => dispatch({ type: "SET_EDITING_STOP", value: stop })}
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
onDeleteError={(msg) => dispatch({ type: "SET_DELETE_ERROR", value: msg })}
|
||||
isLoading={false}
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-[var(--admin-border)] mt-4 pt-4">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, sorted.length)} of {sorted.length}
|
||||
</span>
|
||||
<AdminPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StopsPagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
totalCount={sorted.length}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => dispatch({ type: "SET_PAGE", value: p })}
|
||||
/>
|
||||
{showImport && (
|
||||
<ScheduleImportModal
|
||||
brandId={stops[0]?.brand_id ?? ""}
|
||||
onClose={() => setShowImport(false)}
|
||||
onClose={() => dispatch({ type: "SET_SHOW_IMPORT", value: false })}
|
||||
onComplete={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditStopModal
|
||||
isOpen={showAddModal || editingStop !== null}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setEditingStop(null);
|
||||
}}
|
||||
onClose={() => dispatch({ type: "CLOSE_EDIT_MODAL" })}
|
||||
brandId={stops[0]?.brand_id ?? ""}
|
||||
stop={editingStop}
|
||||
onSuccess={refresh}
|
||||
@@ -454,6 +424,229 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page Header ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StopsPageHeader({
|
||||
sortedCount,
|
||||
hasFilters,
|
||||
onUpload,
|
||||
onAdd,
|
||||
}: {
|
||||
sortedCount: number;
|
||||
hasFilters: boolean;
|
||||
onUpload: () => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--admin-accent)]/10">
|
||||
<StopIconHeader />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
|
||||
Stops & Routes
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{sortedCount} stop{sortedCount !== 1 ? "s" : ""}
|
||||
{hasFilters ? " matching filters" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onUpload}
|
||||
icon={Icons.upload("h-4 w-4")}
|
||||
>
|
||||
Upload
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
icon={Icons.plus("h-4 w-4")}
|
||||
>
|
||||
Add Stop
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stats Grid ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsGrid({
|
||||
stats,
|
||||
}: {
|
||||
stats: { total: number; active: number; upcoming: number; draft: number };
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1 tabular-nums">
|
||||
{stats.total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1 tabular-nums">
|
||||
{stats.active}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Upcoming</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-sky-600 mt-1 tabular-nums">
|
||||
{stats.upcoming}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Drafts</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1 tabular-nums">
|
||||
{stats.draft}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Filter Bar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = { value: string; label: string; count: number };
|
||||
|
||||
function StopsFilterBar({
|
||||
statusFilter,
|
||||
search,
|
||||
viewMode,
|
||||
tabs,
|
||||
onStatusFilterChange,
|
||||
onSearchChange,
|
||||
onSearchClear,
|
||||
onViewModeChange,
|
||||
}: {
|
||||
statusFilter: StatusFilter;
|
||||
search: string;
|
||||
viewMode: ViewMode;
|
||||
tabs: Tab[];
|
||||
onStatusFilterChange: (value: string) => void;
|
||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSearchClear: () => void;
|
||||
onViewModeChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={onStatusFilterChange}
|
||||
tabs={tabs}
|
||||
size="md"
|
||||
/>
|
||||
<AdminSearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
onClear={onSearchClear}
|
||||
placeholder="Search stops..."
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
<AdminViewModeTabs
|
||||
activeTab={viewMode}
|
||||
onTabChange={onViewModeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bulk Actions Bar ───────────────────────────────────────────────────────────
|
||||
|
||||
function BulkActionsBar({
|
||||
selectedCount,
|
||||
bulkPublishing,
|
||||
onClear,
|
||||
onPublish,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
bulkPublishing: boolean;
|
||||
onClear: () => void;
|
||||
onPublish: () => void;
|
||||
}) {
|
||||
if (selectedCount === 0) return null;
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
|
||||
{selectedCount} stop{selectedCount !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<button type="button"
|
||||
onClick={onClear}
|
||||
className="text-xs text-stone-500 hover:text-stone-700"
|
||||
aria-label="Clear">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onPublish}
|
||||
isLoading={bulkPublishing}
|
||||
>
|
||||
Publish Selected
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Delete Error Banner ────────────────────────────────────────────────────────
|
||||
|
||||
function DeleteErrorBanner({
|
||||
error,
|
||||
onDismiss,
|
||||
}: {
|
||||
error: string | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||||
{error}{" "}
|
||||
<button type="button" onClick={onDismiss} className="underline hover:no-underline" aria-label="Dismiss">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pagination Section ─────────────────────────────────────────────────────────
|
||||
|
||||
function StopsPagination({
|
||||
page,
|
||||
pageSize,
|
||||
totalCount,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
onPageChange: (p: number) => void;
|
||||
}) {
|
||||
if (totalPages <= 1) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-[var(--admin-border)] mt-4 pt-4">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, totalCount)} of {totalCount}
|
||||
</span>
|
||||
<AdminPagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TableView({
|
||||
@@ -651,16 +844,16 @@ function SortIcon({ field, sortField, sortDirection }: { field: SortField; sortF
|
||||
function formatDate(iso: string): { date: string; weekday: string; isToday: boolean; isPast: boolean } {
|
||||
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
|
||||
if (!y || !m || !d) return { date: iso, weekday: "", isToday: false, isPast: false };
|
||||
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split("T")[0];
|
||||
|
||||
|
||||
const dt = new Date(y, m - 1, d);
|
||||
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const month = dt.toLocaleDateString("en-US", { month: "short" });
|
||||
|
||||
return {
|
||||
date: `${month} ${d}`,
|
||||
|
||||
return {
|
||||
date: `${month} ${d}`,
|
||||
weekday,
|
||||
isToday: iso === todayStr,
|
||||
isPast: iso < todayStr
|
||||
@@ -677,7 +870,43 @@ function formatTime(t: string): string {
|
||||
return `${hh}:${mStr} ${period}`;
|
||||
}
|
||||
|
||||
// ── Row Component ───────────────────────────────────────────────────────────────
|
||||
// ── StopRow (reducer-based) ────────────────────────────────────────────────────
|
||||
|
||||
type RowState = {
|
||||
openMenu: string | null;
|
||||
deletingId: string | null;
|
||||
confirmDelete: string | null;
|
||||
publishingId: string | null;
|
||||
};
|
||||
|
||||
type RowAction =
|
||||
| { type: "TOGGLE_MENU"; stopId: string }
|
||||
| { type: "CLOSE_MENU" }
|
||||
| { type: "SET_DELETING"; stopId: string | null }
|
||||
| { type: "SET_CONFIRM_DELETE"; stopId: string | null }
|
||||
| { type: "SET_PUBLISHING"; stopId: string | null };
|
||||
|
||||
const initialRowState: RowState = {
|
||||
openMenu: null,
|
||||
deletingId: null,
|
||||
confirmDelete: null,
|
||||
publishingId: null,
|
||||
};
|
||||
|
||||
function rowReducer(state: RowState, action: RowAction): RowState {
|
||||
switch (action.type) {
|
||||
case "TOGGLE_MENU":
|
||||
return { ...state, openMenu: state.openMenu === action.stopId ? null : action.stopId };
|
||||
case "CLOSE_MENU":
|
||||
return { ...state, openMenu: null, confirmDelete: null };
|
||||
case "SET_DELETING":
|
||||
return { ...state, deletingId: action.stopId };
|
||||
case "SET_CONFIRM_DELETE":
|
||||
return { ...state, confirmDelete: action.stopId };
|
||||
case "SET_PUBLISHING":
|
||||
return { ...state, publishingId: action.stopId };
|
||||
}
|
||||
}
|
||||
|
||||
function StopRow({
|
||||
stop,
|
||||
@@ -697,18 +926,16 @@ function StopRow({
|
||||
onToggleSelect: () => void;
|
||||
}) {
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
const [rowState, dispatch] = useReducer(rowReducer, initialRowState);
|
||||
const { openMenu, deletingId, confirmDelete, publishingId } = rowState;
|
||||
|
||||
const { date, weekday, isToday, isPast } = formatDate(stop.date);
|
||||
const time = formatTime(stop.time);
|
||||
|
||||
async function handlePublish() {
|
||||
setPublishingId(stop.id);
|
||||
dispatch({ type: "SET_PUBLISHING", stopId: stop.id });
|
||||
const result = await publishStop(stop.id, stop.brand_id);
|
||||
setPublishingId(null);
|
||||
dispatch({ type: "SET_PUBLISHING", stopId: null });
|
||||
if (result.success) {
|
||||
showSuccess("Stop published", "The stop is now visible on the storefront");
|
||||
onDeleted();
|
||||
@@ -718,11 +945,10 @@ function StopRow({
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeletingId(stop.id);
|
||||
dispatch({ type: "SET_DELETING", stopId: stop.id });
|
||||
const result = await deleteStop(stop.id, stop.brand_id);
|
||||
setDeletingId(null);
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
dispatch({ type: "SET_DELETING", stopId: null });
|
||||
dispatch({ type: "CLOSE_MENU" });
|
||||
if (result.success) {
|
||||
showSuccess("Stop deleted", "The stop has been removed");
|
||||
onDeleted();
|
||||
@@ -735,8 +961,8 @@ function StopRow({
|
||||
<tr
|
||||
onClick={onEdit}
|
||||
className={`group cursor-pointer transition-all duration-150 ${
|
||||
isSelected
|
||||
? "bg-emerald-50/60"
|
||||
isSelected
|
||||
? "bg-emerald-50/60"
|
||||
: "hover:bg-stone-50/80"
|
||||
} ${isPast && stop.status !== "draft" ? "opacity-60" : ""}`}
|
||||
>
|
||||
@@ -826,7 +1052,7 @@ function StopRow({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenMenu(openMenu === stop.id ? null : stop.id);
|
||||
dispatch({ type: "TOGGLE_MENU", stopId: stop.id });
|
||||
}}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
aria-label="Stop actions"
|
||||
@@ -842,18 +1068,15 @@ function StopRow({
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
|
||||
onClick={() => {
|
||||
setOpenMenu(null);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onClick={() => dispatch({ type: "CLOSE_MENU" })}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
|
||||
{stop.status === "draft" && (
|
||||
<button type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePublish();
|
||||
setOpenMenu(null);
|
||||
void handlePublish();
|
||||
dispatch({ type: "CLOSE_MENU" });
|
||||
}}
|
||||
disabled={publishingId === stop.id}
|
||||
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
@@ -877,8 +1100,8 @@ function StopRow({
|
||||
<button type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenu(null);
|
||||
setConfirmDelete(stop.id);
|
||||
dispatch({ type: "CLOSE_MENU" });
|
||||
dispatch({ type: "SET_CONFIRM_DELETE", stopId: stop.id });
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center gap-2"
|
||||
aria-label="Delete">
|
||||
@@ -897,10 +1120,7 @@ function StopRow({
|
||||
type="button"
|
||||
aria-label="Cancel delete confirmation"
|
||||
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
|
||||
onClick={() => {
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
onClick={() => dispatch({ type: "CLOSE_MENU" })}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-800">
|
||||
@@ -915,8 +1135,7 @@ function StopRow({
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
dispatch({ type: "CLOSE_MENU" });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -926,7 +1145,7 @@ function StopRow({
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
void handleDelete();
|
||||
}}
|
||||
disabled={deletingId === stop.id}
|
||||
isLoading={deletingId === stop.id}
|
||||
@@ -943,7 +1162,33 @@ function StopRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card Component ─────────────────────────────────────────────────────────────
|
||||
// ── StopCard (reducer-based) ───────────────────────────────────────────────────
|
||||
|
||||
type CardState = {
|
||||
confirmDelete: boolean;
|
||||
deleting: boolean;
|
||||
};
|
||||
|
||||
type CardAction =
|
||||
| { type: "OPEN_CONFIRM" }
|
||||
| { type: "CLOSE_CONFIRM" }
|
||||
| { type: "SET_DELETING"; value: boolean };
|
||||
|
||||
const initialCardState: CardState = {
|
||||
confirmDelete: false,
|
||||
deleting: false,
|
||||
};
|
||||
|
||||
function cardReducer(state: CardState, action: CardAction): CardState {
|
||||
switch (action.type) {
|
||||
case "OPEN_CONFIRM":
|
||||
return { ...state, confirmDelete: true };
|
||||
case "CLOSE_CONFIRM":
|
||||
return { ...state, confirmDelete: false };
|
||||
case "SET_DELETING":
|
||||
return { ...state, deleting: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
function StopCard({
|
||||
stop,
|
||||
@@ -956,17 +1201,17 @@ function StopCard({
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
}) {
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { success: showSuccess } = useToast();
|
||||
const [cardState, dispatch] = useReducer(cardReducer, initialCardState);
|
||||
const { confirmDelete, deleting } = cardState;
|
||||
|
||||
const { date, weekday, isToday, isPast } = formatDate(stop.date);
|
||||
const time = formatTime(stop.time);
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
dispatch({ type: "SET_DELETING", value: true });
|
||||
const result = await deleteStop(stop.id, stop.brand_id);
|
||||
setDeleting(false);
|
||||
dispatch({ type: "SET_DELETING", value: false });
|
||||
if (result.success) {
|
||||
showSuccess("Stop deleted", "The stop has been removed");
|
||||
onDeleted();
|
||||
@@ -1036,7 +1281,7 @@ function StopCard({
|
||||
</AdminButton>
|
||||
<button type="button"
|
||||
aria-label={`Delete stop in ${stop.city}, ${stop.state}`}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
onClick={() => dispatch({ type: "OPEN_CONFIRM" })}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -1058,13 +1303,13 @@ function StopCard({
|
||||
</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button type="button"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
onClick={() => dispatch({ type: "CLOSE_CONFIRM" })}
|
||||
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||||
aria-label="Cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleDelete}
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { formatDate } from "@/lib/date-utils";
|
||||
import { useReducer, useCallback, useEffect } from "react";
|
||||
import {
|
||||
exportTaxReport,
|
||||
exportTaxByState,
|
||||
@@ -53,7 +52,7 @@ function downloadCSV(csv: string, filename: string) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function quarterLabel(start: string, end: string): string {
|
||||
function quarterLabel(start: string): string {
|
||||
const s = new Date(start + "T00:00:00");
|
||||
const q = Math.floor(s.getMonth() / 3) + 1;
|
||||
return `Q${q} ${s.getFullYear()}`;
|
||||
@@ -84,7 +83,7 @@ function SummaryCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ExportBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
function ExportBtn({ onClick }: { label?: string; onClick: () => void }) {
|
||||
return (
|
||||
<AdminButton
|
||||
onClick={onClick}
|
||||
@@ -121,7 +120,7 @@ function ReportTable({ headers, rows, renderRow }: {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((_, i) => renderRow(_, i))
|
||||
rows.map((row, i) => renderRow(row, i))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -134,6 +133,67 @@ const TABS = [
|
||||
{ value: "orders", label: "Taxable Orders" },
|
||||
];
|
||||
|
||||
type State = {
|
||||
preset: DatePreset;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
selectedBrandId: string;
|
||||
activeTab: string;
|
||||
summary: TaxSummaryDataLocal | null;
|
||||
orders: TaxOrderRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_PRESET"; preset: DatePreset }
|
||||
| { type: "SET_CUSTOM_START"; value: string }
|
||||
| { type: "SET_CUSTOM_END"; value: string }
|
||||
| { type: "SET_BRAND"; brandId: string }
|
||||
| { type: "SET_TAB"; tab: string }
|
||||
| { type: "SET_SUMMARY"; data: TaxSummaryDataLocal }
|
||||
| { type: "SET_ORDERS"; data: TaxOrderRow[] }
|
||||
| { type: "RESET_DATA" }
|
||||
| { type: "SET_LOADING"; value: boolean }
|
||||
| { type: "SET_ERROR"; error: string | null };
|
||||
|
||||
const initialState: State = {
|
||||
preset: "quarter",
|
||||
customStart: "",
|
||||
customEnd: "",
|
||||
selectedBrandId: "",
|
||||
activeTab: "summary",
|
||||
summary: null,
|
||||
orders: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_PRESET":
|
||||
return { ...state, preset: action.preset };
|
||||
case "SET_CUSTOM_START":
|
||||
return { ...state, customStart: action.value };
|
||||
case "SET_CUSTOM_END":
|
||||
return { ...state, customEnd: action.value };
|
||||
case "SET_BRAND":
|
||||
return { ...state, selectedBrandId: action.brandId };
|
||||
case "SET_TAB":
|
||||
return { ...state, activeTab: action.tab };
|
||||
case "SET_SUMMARY":
|
||||
return { ...state, summary: action.data };
|
||||
case "SET_ORDERS":
|
||||
return { ...state, orders: action.data };
|
||||
case "RESET_DATA":
|
||||
return { ...state, summary: null, orders: [] };
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.error };
|
||||
}
|
||||
}
|
||||
|
||||
export default function TaxDashboard({
|
||||
brands,
|
||||
initialBrandId,
|
||||
@@ -143,17 +203,12 @@ export default function TaxDashboard({
|
||||
initialBrandId: string | null;
|
||||
isPlatformAdmin: boolean;
|
||||
}) {
|
||||
const [preset, setPreset] = useState<DatePreset>("quarter");
|
||||
const [customStart, setCustomStart] = useState("");
|
||||
const [customEnd, setCustomEnd] = useState("");
|
||||
const [selectedBrandId, setSelectedBrandId] = useState<string>(initialBrandId ?? "");
|
||||
const [activeTab, setActiveTab] = useState("summary");
|
||||
const [summary, setSummary] = useState<TaxSummaryDataLocal | null>(null);
|
||||
const [orders, setOrders] = useState<TaxOrderRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
...initialState,
|
||||
selectedBrandId: initialBrandId ?? "",
|
||||
});
|
||||
|
||||
const range: DateRange = buildRange(preset, customStart, customEnd);
|
||||
const range: DateRange = buildRange(state.preset, state.customStart, state.customEnd);
|
||||
|
||||
// Year is read from the clock inline at the rendering site so we never
|
||||
// need to mirror it into React state. This sidesteps the "fake event
|
||||
@@ -163,101 +218,91 @@ export default function TaxDashboard({
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const fetchTaxSummary = useCallback(async () => {
|
||||
if (!selectedBrandId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!state.selectedBrandId) return;
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
dispatch({ type: "SET_ERROR", error: null });
|
||||
|
||||
try {
|
||||
const result = await getTaxSummaryAction({
|
||||
brandId: selectedBrandId,
|
||||
brandId: state.selectedBrandId,
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error);
|
||||
setSummary(result.data);
|
||||
dispatch({ type: "SET_SUMMARY", data: result.data });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load tax summary");
|
||||
dispatch({ type: "SET_ERROR", error: err instanceof Error ? err.message : "Failed to load tax summary" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
}
|
||||
}, [selectedBrandId, range]);
|
||||
}, [state.selectedBrandId, range.start, range.end]);
|
||||
|
||||
const fetchTaxableOrders = useCallback(async () => {
|
||||
if (!selectedBrandId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!state.selectedBrandId) return;
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
dispatch({ type: "SET_ERROR", error: null });
|
||||
|
||||
try {
|
||||
const result = await getTaxableOrdersAction({
|
||||
brandId: selectedBrandId,
|
||||
brandId: state.selectedBrandId,
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error);
|
||||
setOrders(result.data);
|
||||
dispatch({ type: "SET_ORDERS", data: result.data });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load orders");
|
||||
dispatch({ type: "SET_ERROR", error: err instanceof Error ? err.message : "Failed to load orders" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
}
|
||||
}, [selectedBrandId, range]);
|
||||
}, [state.selectedBrandId, range.start, range.end]);
|
||||
|
||||
function handleFetch() {
|
||||
if (activeTab === "summary") fetchTaxSummary();
|
||||
else fetchTaxableOrders();
|
||||
}
|
||||
const fetchForCurrentTab = useCallback(async () => {
|
||||
if (!state.selectedBrandId) return;
|
||||
if (state.activeTab === "summary") {
|
||||
await fetchTaxSummary();
|
||||
} else {
|
||||
await fetchTaxableOrders();
|
||||
}
|
||||
}, [state.selectedBrandId, state.activeTab, fetchTaxSummary, fetchTaxableOrders]);
|
||||
|
||||
// Initial fetch on mount — runs once when the dashboard first renders.
|
||||
// Subsequent fetches happen inside the change handlers below, so we
|
||||
// never have to watch state changes from a useEffect.
|
||||
useEffect(() => {
|
||||
if (!selectedBrandId) return;
|
||||
if (activeTab === "summary") {
|
||||
void fetchTaxSummary();
|
||||
} else {
|
||||
void fetchTaxableOrders();
|
||||
}
|
||||
if (!state.selectedBrandId) return;
|
||||
void fetchForCurrentTab();
|
||||
// selectedBrandId + activeTab are stable for this initial-load path;
|
||||
// re-runs only on mount.
|
||||
}, [selectedBrandId, activeTab, fetchTaxSummary, fetchTaxableOrders]);
|
||||
}, [state.selectedBrandId, fetchForCurrentTab]);
|
||||
|
||||
function handlePresetChange(p: DatePreset) {
|
||||
setPreset(p);
|
||||
dispatch({ type: "SET_PRESET", preset: p });
|
||||
if (p !== "custom") {
|
||||
void fetchForCurrentTab();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomDatesChange() {
|
||||
if (preset === "custom") {
|
||||
if (state.preset === "custom") {
|
||||
void fetchForCurrentTab();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBrandChange(brandId: string) {
|
||||
setSelectedBrandId(brandId);
|
||||
setSummary(null);
|
||||
setOrders([]);
|
||||
dispatch({ type: "SET_BRAND", brandId });
|
||||
dispatch({ type: "RESET_DATA" });
|
||||
void fetchForCurrentTab();
|
||||
}
|
||||
|
||||
function handleTabChange(tab: string) {
|
||||
setActiveTab(tab);
|
||||
dispatch({ type: "SET_TAB", tab });
|
||||
void fetchForCurrentTab();
|
||||
}
|
||||
|
||||
async function fetchForCurrentTab() {
|
||||
if (!selectedBrandId) return;
|
||||
if (activeTab === "summary") {
|
||||
await fetchTaxSummary();
|
||||
} else {
|
||||
await fetchTaxableOrders();
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportCSV() {
|
||||
if (activeTab === "summary" && summary) {
|
||||
const rows: TaxByStateRow[] = summary.tax_by_state.map((s) => ({
|
||||
if (state.activeTab === "summary" && state.summary) {
|
||||
const rows: TaxByStateRow[] = state.summary.tax_by_state.map((s) => ({
|
||||
state: s.state,
|
||||
total_tax: s.total_tax,
|
||||
gross_sales: s.gross_sales,
|
||||
@@ -265,195 +310,276 @@ export default function TaxDashboard({
|
||||
}));
|
||||
const csv = exportTaxByState(rows);
|
||||
downloadCSV(csv, `tax-by-state-${range.start}.csv`);
|
||||
} else if (activeTab === "orders" && orders.length > 0) {
|
||||
const csv = exportTaxReport(orders);
|
||||
} else if (state.activeTab === "orders" && state.orders.length > 0) {
|
||||
const csv = exportTaxReport(state.orders);
|
||||
downloadCSV(csv, `taxable-orders-${range.start}.csv`);
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveRate = summary && summary.total_gross_sales > 0
|
||||
? (summary.total_tax_collected / summary.total_gross_sales) * 100
|
||||
const effectiveRate = state.summary && state.summary.total_gross_sales > 0
|
||||
? (state.summary.total_tax_collected / state.summary.total_gross_sales) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
{state.error && (
|
||||
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filters ── */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-5 py-4">
|
||||
<div className="flex gap-1">
|
||||
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
|
||||
<AdminButton
|
||||
key={p}
|
||||
onClick={() => handlePresetChange(p)}
|
||||
variant={preset === p ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
|
||||
</AdminButton>
|
||||
))}
|
||||
</div>
|
||||
<FilterBar
|
||||
preset={state.preset}
|
||||
customStart={state.customStart}
|
||||
customEnd={state.customEnd}
|
||||
selectedBrandId={state.selectedBrandId}
|
||||
loading={state.loading}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
brands={brands}
|
||||
rangeStart={range.start}
|
||||
rangeEnd={range.end}
|
||||
currentYear={currentYear}
|
||||
onPresetChange={handlePresetChange}
|
||||
onCustomStartChange={(value) => {
|
||||
dispatch({ type: "SET_CUSTOM_START", value });
|
||||
handleCustomDatesChange();
|
||||
}}
|
||||
onCustomEndChange={(value) => {
|
||||
dispatch({ type: "SET_CUSTOM_END", value });
|
||||
handleCustomDatesChange();
|
||||
}}
|
||||
onBrandChange={handleBrandChange}
|
||||
onRefresh={fetchForCurrentTab}
|
||||
/>
|
||||
|
||||
{preset === "custom" && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<input aria-label="Date"
|
||||
type="date"
|
||||
value={customStart}
|
||||
onChange={(e) => {
|
||||
setCustomStart(e.target.value);
|
||||
handleCustomDatesChange();
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">to</span>
|
||||
<input aria-label="Date"
|
||||
type="date"
|
||||
value={customEnd}
|
||||
onChange={(e) => {
|
||||
setCustomEnd(e.target.value);
|
||||
handleCustomDatesChange();
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPlatformAdmin && (
|
||||
<select aria-label="Select"
|
||||
value={selectedBrandId}
|
||||
onChange={(e) => handleBrandChange(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<AdminButton
|
||||
onClick={handleFetch}
|
||||
disabled={loading || !selectedBrandId}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
|
||||
<span className="ml-auto text-xs text-zinc-500">
|
||||
{preset === "quarter" ? quarterLabel(range.start, range.end) :
|
||||
preset === "this_year" ? `${currentYear} YTD` :
|
||||
`${range.start} → ${range.end}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Tab bar ── */}
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
activeTab={state.activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tabs={TABS}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
/>
|
||||
|
||||
{/* ── TAX SUMMARY TAB ── */}
|
||||
{activeTab === "summary" && (
|
||||
<div className="space-y-4">
|
||||
{summary && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard
|
||||
label="Total Tax Collected"
|
||||
value={summary.total_tax_collected}
|
||||
prefix="$"
|
||||
highlight
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Total Gross Sales"
|
||||
value={summary.total_gross_sales}
|
||||
prefix="$"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Effective Tax Rate"
|
||||
value={effectiveRate}
|
||||
suffix="%"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Taxable Orders"
|
||||
value={summary.order_count}
|
||||
/>
|
||||
</div>
|
||||
{state.activeTab === "summary" && (
|
||||
<SummaryTab
|
||||
summary={state.summary}
|
||||
loading={state.loading}
|
||||
effectiveRate={effectiveRate}
|
||||
onExport={handleExportCSV}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Tax by State</h3>
|
||||
{summary.tax_by_state.length > 0 && (
|
||||
<ExportBtn label="Export CSV" onClick={handleExportCSV} />
|
||||
)}
|
||||
</div>
|
||||
<ReportTable
|
||||
headers={["State", "Total Tax", "Gross Sales", "Orders", "Eff. Rate"]}
|
||||
rows={summary.tax_by_state}
|
||||
renderRow={(row) => {
|
||||
const r = row as { state: string; total_tax: number; gross_sales: number; order_count: number };
|
||||
const rate = r.gross_sales > 0 ? (r.total_tax / r.gross_sales) * 100 : 0;
|
||||
return (
|
||||
<tr key={r.state} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.state}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-700 font-medium">${r.total_tax.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right">{r.order_count}</td>
|
||||
<td className="px-3 py-2 text-right text-zinc-500">{rate.toFixed(2)}%</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{state.activeTab === "orders" && (
|
||||
<OrdersTab orders={state.orders} onExport={handleExportCSV} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{!summary && !loading && (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-6 py-12 text-center text-slate-400">
|
||||
Click Refresh to load tax summary
|
||||
</div>
|
||||
)}
|
||||
type FilterBarProps = {
|
||||
preset: DatePreset;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
selectedBrandId: string;
|
||||
loading: boolean;
|
||||
isPlatformAdmin: boolean;
|
||||
brands: { id: string; name: string }[];
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
currentYear: number;
|
||||
onPresetChange: (p: DatePreset) => void;
|
||||
onCustomStartChange: (value: string) => void;
|
||||
onCustomEndChange: (value: string) => void;
|
||||
onBrandChange: (brandId: string) => void;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function FilterBar({
|
||||
preset,
|
||||
customStart,
|
||||
customEnd,
|
||||
selectedBrandId,
|
||||
loading,
|
||||
isPlatformAdmin,
|
||||
brands,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
currentYear,
|
||||
onPresetChange,
|
||||
onCustomStartChange,
|
||||
onCustomEndChange,
|
||||
onBrandChange,
|
||||
onRefresh,
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-5 py-4">
|
||||
<div className="flex gap-1">
|
||||
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
|
||||
<AdminButton
|
||||
key={p}
|
||||
onClick={() => onPresetChange(p)}
|
||||
variant={preset === p ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
|
||||
</AdminButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{preset === "custom" && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<input aria-label="Date"
|
||||
type="date"
|
||||
value={customStart}
|
||||
onChange={(e) => onCustomStartChange(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">to</span>
|
||||
<input aria-label="Date"
|
||||
type="date"
|
||||
value={customEnd}
|
||||
onChange={(e) => onCustomEndChange(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TAXABLE ORDERS TAB ── */}
|
||||
{activeTab === "orders" && (
|
||||
<div className="space-y-3">
|
||||
{isPlatformAdmin && (
|
||||
<select aria-label="Select"
|
||||
value={selectedBrandId}
|
||||
onChange={(e) => onBrandChange(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<AdminButton
|
||||
onClick={() => void onRefresh()}
|
||||
disabled={loading || !selectedBrandId}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
|
||||
<span className="ml-auto text-xs text-zinc-500">
|
||||
{preset === "quarter" ? quarterLabel(rangeStart) :
|
||||
preset === "this_year" ? `${currentYear} YTD` :
|
||||
`${rangeStart} → ${rangeEnd}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SummaryTabProps = {
|
||||
summary: TaxSummaryDataLocal | null;
|
||||
loading: boolean;
|
||||
effectiveRate: number;
|
||||
onExport: () => void;
|
||||
};
|
||||
|
||||
function SummaryTab({ summary, loading, effectiveRate, onExport }: SummaryTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{summary && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard
|
||||
label="Total Tax Collected"
|
||||
value={summary.total_tax_collected}
|
||||
prefix="$"
|
||||
highlight
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Total Gross Sales"
|
||||
value={summary.total_gross_sales}
|
||||
prefix="$"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Effective Tax Rate"
|
||||
value={effectiveRate}
|
||||
suffix="%"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Taxable Orders"
|
||||
value={summary.order_count}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Taxable Orders</h3>
|
||||
{orders.length > 0 && (
|
||||
<ExportBtn label="Export CSV" onClick={handleExportCSV} />
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Tax by State</h3>
|
||||
{summary.tax_by_state.length > 0 && (
|
||||
<ExportBtn label="Export CSV" onClick={onExport} />
|
||||
)}
|
||||
</div>
|
||||
<ReportTable
|
||||
headers={["Order ID", "Date", "Customer", "City", "State", "Taxable Amt", "Tax", "Rate", "Location"]}
|
||||
rows={orders}
|
||||
headers={["State", "Total Tax", "Gross Sales", "Orders", "Eff. Rate"]}
|
||||
rows={summary.tax_by_state}
|
||||
renderRow={(row) => {
|
||||
const r = row as TaxOrderRow;
|
||||
const r = row as { state: string; total_tax: number; gross_sales: number; order_count: number };
|
||||
const rate = r.gross_sales > 0 ? (r.total_tax / r.gross_sales) * 100 : 0;
|
||||
return (
|
||||
<tr key={r.order_id} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-mono text-xs text-zinc-400">{r.order_id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.date}</td>
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.customer_name}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.state}</td>
|
||||
<td className="px-3 py-2 text-right">${Number(r.taxable_amount).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-700 font-medium">${Number(r.tax_amount).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-zinc-500">{Number(r.tax_rate).toFixed(4)}</td>
|
||||
<td className="px-3 py-2 text-zinc-500 text-xs">{r.tax_location}</td>
|
||||
<tr key={r.state} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.state}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-700 font-medium">${r.total_tax.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right">{r.order_count}</td>
|
||||
<td className="px-3 py-2 text-right text-zinc-500">{rate.toFixed(2)}%</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!summary && !loading && (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-6 py-12 text-center text-slate-400">
|
||||
Click Refresh to load tax summary
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OrdersTabProps = {
|
||||
orders: TaxOrderRow[];
|
||||
onExport: () => void;
|
||||
};
|
||||
|
||||
function OrdersTab({ orders, onExport }: OrdersTabProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Taxable Orders</h3>
|
||||
{orders.length > 0 && (
|
||||
<ExportBtn label="Export CSV" onClick={onExport} />
|
||||
)}
|
||||
</div>
|
||||
<ReportTable
|
||||
headers={["Order ID", "Date", "Customer", "City", "State", "Taxable Amt", "Tax", "Rate", "Location"]}
|
||||
rows={orders}
|
||||
renderRow={(row) => {
|
||||
const r = row as TaxOrderRow;
|
||||
return (
|
||||
<tr key={r.order_id} className="border-t border-slate-100 hover:bg-zinc-800">
|
||||
<td className="px-3 py-2 font-mono text-xs text-zinc-400">{r.order_id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.date}</td>
|
||||
<td className="px-3 py-2 font-medium text-zinc-100">{r.customer_name}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{r.state}</td>
|
||||
<td className="px-3 py-2 text-right">${Number(r.taxable_amount).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-700 font-medium">${Number(r.tax_amount).toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right text-zinc-500">{Number(r.tax_rate).toFixed(4)}</td>
|
||||
<td className="px-3 py-2 text-zinc-500 text-xs">{r.tax_location}</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+935
-524
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateWaterIrrigator, resetWaterIrrigatorPin, deleteWaterUser } from "@/actions/water-log/admin";
|
||||
import AdminButton from "./design-system/AdminButton";
|
||||
@@ -20,82 +20,135 @@ type Props = {
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
name?: string;
|
||||
role?: "irrigator" | "water_admin";
|
||||
active?: boolean;
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
draft: Draft;
|
||||
saving: boolean;
|
||||
deleting: boolean;
|
||||
resetting: boolean;
|
||||
newPin: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_NAME"; value: string }
|
||||
| { type: "SET_ROLE"; value: "irrigator" | "water_admin" }
|
||||
| { type: "SET_ACTIVE"; value: boolean }
|
||||
| { type: "SET_LANG"; value: string }
|
||||
| { type: "START_SAVE" }
|
||||
| { type: "SAVE_FAIL"; error: string }
|
||||
| { type: "START_DELETE" }
|
||||
| { type: "DELETE_FAIL"; error: string }
|
||||
| { type: "START_RESET_PIN" }
|
||||
| { type: "RESET_PIN_SUCCESS"; pin: string }
|
||||
| { type: "RESET_PIN_FAIL"; error: string }
|
||||
| { type: "DISMISS_PIN" }
|
||||
| { type: "SET_ERROR"; error: string | null };
|
||||
|
||||
const initialState: State = {
|
||||
draft: {},
|
||||
saving: false,
|
||||
deleting: false,
|
||||
resetting: false,
|
||||
newPin: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_NAME":
|
||||
return { ...state, draft: { ...state.draft, name: action.value } };
|
||||
case "SET_ROLE":
|
||||
return { ...state, draft: { ...state.draft, role: action.value } };
|
||||
case "SET_ACTIVE":
|
||||
return { ...state, draft: { ...state.draft, active: action.value } };
|
||||
case "SET_LANG":
|
||||
return { ...state, draft: { ...state.draft, lang: action.value } };
|
||||
case "START_SAVE":
|
||||
return { ...state, saving: true, error: null };
|
||||
case "SAVE_FAIL":
|
||||
return { ...state, saving: false, error: action.error };
|
||||
case "START_DELETE":
|
||||
return { ...state, deleting: true, error: null };
|
||||
case "DELETE_FAIL":
|
||||
return { ...state, deleting: false, error: action.error };
|
||||
case "START_RESET_PIN":
|
||||
return { ...state, resetting: true, error: null };
|
||||
case "RESET_PIN_SUCCESS":
|
||||
return { ...state, resetting: false, newPin: action.pin };
|
||||
case "RESET_PIN_FAIL":
|
||||
return { ...state, resetting: false, error: action.error };
|
||||
case "DISMISS_PIN":
|
||||
return { ...state, newPin: null };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.error };
|
||||
}
|
||||
}
|
||||
|
||||
export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-log" }: Props) {
|
||||
const router = useRouter();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
// Holds user edits only; missing keys fall back to the current prop so we
|
||||
// never copy the prop into useState. When the prop changes, the fallback
|
||||
// value tracks the new prop automatically.
|
||||
const [draft, setDraft] = useState<{
|
||||
name?: string;
|
||||
role?: "irrigator" | "water_admin";
|
||||
active?: boolean;
|
||||
lang?: string;
|
||||
}>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [newPin, setNewPin] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const name = draft.name ?? waterUser.name;
|
||||
const role = draft.role ?? waterUser.role;
|
||||
const active = draft.active ?? waterUser.active;
|
||||
const lang = draft.lang ?? waterUser.language_preference;
|
||||
const setName = (v: string) => setDraft((d) => ({ ...d, name: v }));
|
||||
const setRole = (v: "irrigator" | "water_admin") => setDraft((d) => ({ ...d, role: v }));
|
||||
const setActive = (v: boolean) => setDraft((d) => ({ ...d, active: v }));
|
||||
const setLang = (v: string) => setDraft((d) => ({ ...d, lang: v }));
|
||||
const name = state.draft.name ?? waterUser.name;
|
||||
const role = state.draft.role ?? waterUser.role;
|
||||
const active = state.draft.active ?? waterUser.active;
|
||||
const lang = state.draft.lang ?? waterUser.language_preference;
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
dispatch({ type: "START_SAVE" });
|
||||
const result = await updateWaterIrrigator(waterUser.id, name.trim(), active, lang, role);
|
||||
if (result.success) {
|
||||
router.push(backHref);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
dispatch({ type: "SAVE_FAIL", error: result.error ?? "Failed to save" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPin() {
|
||||
setResetting(true);
|
||||
dispatch({ type: "START_RESET_PIN" });
|
||||
const result = await resetWaterIrrigatorPin(waterUser.id);
|
||||
if (result.success && result.pin) {
|
||||
setNewPin(result.pin);
|
||||
dispatch({ type: "RESET_PIN_SUCCESS", pin: result.pin });
|
||||
} else {
|
||||
setError(result.error ?? "Failed to reset PIN");
|
||||
dispatch({ type: "RESET_PIN_FAIL", error: result.error ?? "Failed to reset PIN" });
|
||||
}
|
||||
setResetting(false);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm(`Delete user "${waterUser.name}"? Their log entries will be preserved.`)) return;
|
||||
setDeleting(true);
|
||||
dispatch({ type: "START_DELETE" });
|
||||
const result = await deleteWaterUser(waterUser.id);
|
||||
if (result.success) {
|
||||
router.push(backHref);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to delete");
|
||||
setDeleting(false);
|
||||
dispatch({ type: "DELETE_FAIL", error: result.error ?? "Failed to delete" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{error && (
|
||||
{state.error && (
|
||||
<div className="rounded-lg bg-red-100 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newPin && (
|
||||
{state.newPin && (
|
||||
<div className="rounded-xl bg-amber-100 border border-amber-200 p-4">
|
||||
<p className="font-semibold text-amber-800">New PIN for {name}:</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-widest text-amber-900">{newPin}</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-widest text-amber-900">{state.newPin}</p>
|
||||
<p className="mt-1 text-xs text-amber-600">Write this down — it will not be shown again.</p>
|
||||
<button type="button" onClick={() => setNewPin(null)} className="mt-2 text-xs text-amber-700 underline">Dismiss</button>
|
||||
<button type="button" onClick={() => dispatch({ type: "DISMISS_PIN" })} className="mt-2 text-xs text-amber-700 underline">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -105,7 +158,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
|
||||
<input id="fld-1-name" aria-label="Text"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_NAME", value: e.target.value })}
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
|
||||
required
|
||||
/>
|
||||
@@ -114,7 +167,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
|
||||
<label htmlFor="fld-2-role" className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Role</label>
|
||||
<select id="fld-2-role" aria-label="Select"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as "irrigator" | "water_admin")}
|
||||
onChange={(e) => dispatch({ type: "SET_ROLE", value: e.target.value as "irrigator" | "water_admin" })}
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="irrigator">Irrigator</option>
|
||||
@@ -130,7 +183,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
|
||||
<label htmlFor="fld-3-language" className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Language</label>
|
||||
<select id="fld-3-language" aria-label="Select"
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value)}
|
||||
onChange={(e) => dispatch({ type: "SET_LANG", value: e.target.value })}
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
@@ -144,7 +197,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
|
||||
<label htmlFor="fld-4-status" className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
|
||||
<select id="fld-4-status" aria-label="Select"
|
||||
value={active ? "1" : "0"}
|
||||
onChange={(e) => setActive(e.target.value === "1")}
|
||||
onChange={(e) => dispatch({ type: "SET_ACTIVE", value: e.target.value === "1" })}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="1">Active</option>
|
||||
@@ -156,8 +209,8 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleResetPin}
|
||||
disabled={resetting}
|
||||
isLoading={resetting}
|
||||
disabled={state.resetting}
|
||||
isLoading={state.resetting}
|
||||
>
|
||||
Reset PIN
|
||||
</AdminButton>
|
||||
@@ -169,15 +222,15 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
isLoading={deleting}
|
||||
disabled={state.deleting}
|
||||
isLoading={state.deleting}
|
||||
>
|
||||
Delete User
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
isLoading={saving}
|
||||
disabled={state.saving}
|
||||
isLoading={state.saving}
|
||||
>
|
||||
Save Changes
|
||||
</AdminButton>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useReducer, useCallback } from "react";
|
||||
import { getWelcomeSequence, resendWelcomeEmail } from "@/actions/email-automation/welcome-sequence";
|
||||
import type { WelcomeSequenceEntry } from "@/lib/welcome-email-sender";
|
||||
|
||||
type Props = { brandId: string };
|
||||
type Filter = "all" | "active" | "completed";
|
||||
type Stats = { total: number; completed: number; active: number; unsubscribed: number };
|
||||
|
||||
const STATUS_LABELS: Record<string, { en: string; color: string }> = {
|
||||
active: { en: "Active", color: "bg-blue-50 text-blue-700 border border-blue-200" },
|
||||
@@ -21,90 +23,95 @@ const STEP_LABELS: Record<number, string> = {
|
||||
4: "Final (72h)",
|
||||
};
|
||||
|
||||
export default function WelcomeSequenceDashboard({ brandId }: Props) {
|
||||
const [entries, setEntries] = useState<WelcomeSequenceEntry[]>([]);
|
||||
const [stats, setStats] = useState({ total: 0, completed: 0, active: 0, unsubscribed: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 25;
|
||||
const setFilterAndResetPage = (next: typeof filter) => {
|
||||
setFilter(next);
|
||||
setPage(0);
|
||||
};
|
||||
const [resending, setResending] = useState<string | null>(null);
|
||||
type State = {
|
||||
entries: WelcomeSequenceEntry[];
|
||||
stats: Stats;
|
||||
loading: boolean;
|
||||
filter: Filter;
|
||||
page: number;
|
||||
resending: string | null;
|
||||
};
|
||||
|
||||
const handleResend = async (entryId: string) => {
|
||||
setResending(entryId);
|
||||
await resendWelcomeEmail(entryId, brandId);
|
||||
await load();
|
||||
setResending(null);
|
||||
};
|
||||
type Action =
|
||||
| { type: "LOAD_START" }
|
||||
| { type: "LOAD_SUCCESS"; entries: WelcomeSequenceEntry[]; stats: Stats }
|
||||
| { type: "LOAD_END" }
|
||||
| { type: "SET_FILTER"; filter: Filter }
|
||||
| { type: "SET_PAGE"; page: number }
|
||||
| { type: "SET_RESENDING"; value: string | null };
|
||||
|
||||
const initialState: State = {
|
||||
entries: [],
|
||||
stats: { total: 0, completed: 0, active: 0, unsubscribed: 0 },
|
||||
loading: false,
|
||||
filter: "all",
|
||||
page: 0,
|
||||
resending: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "LOAD_START":
|
||||
return { ...state, loading: true };
|
||||
case "LOAD_SUCCESS":
|
||||
return { ...state, entries: action.entries, stats: action.stats };
|
||||
case "LOAD_END":
|
||||
return { ...state, loading: false };
|
||||
case "SET_FILTER":
|
||||
return { ...state, filter: action.filter, page: 0 };
|
||||
case "SET_PAGE":
|
||||
return { ...state, page: action.page };
|
||||
case "SET_RESENDING":
|
||||
return { ...state, resending: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function WelcomeSequenceDashboard({ brandId }: Props) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { entries, stats, loading, filter, page, resending } = state;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
dispatch({ type: "LOAD_START" });
|
||||
const result = await getWelcomeSequence(brandId);
|
||||
if (result.success) {
|
||||
setEntries(result.entries);
|
||||
setStats(result.stats);
|
||||
dispatch({ type: "LOAD_SUCCESS", entries: result.entries, stats: result.stats });
|
||||
}
|
||||
setLoading(false);
|
||||
dispatch({ type: "LOAD_END" });
|
||||
}, [brandId]);
|
||||
|
||||
const handleResend = async (entryId: string) => {
|
||||
dispatch({ type: "SET_RESENDING", value: entryId });
|
||||
await resendWelcomeEmail(entryId, brandId);
|
||||
await load();
|
||||
dispatch({ type: "SET_RESENDING", value: null });
|
||||
};
|
||||
|
||||
const filtered = entries.filter(e => {
|
||||
if (filter === "active") return e.status === "active";
|
||||
if (filter === "completed") return e.status === "completed";
|
||||
return true;
|
||||
});
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const paginatedEntries = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total", value: stats.total, color: "text-[var(--admin-text-primary)]" },
|
||||
{ label: "Active", value: stats.active, color: "text-blue-600" },
|
||||
{ label: "Completed", value: stats.completed, color: "text-green-600" },
|
||||
{ label: "Unsubscribed", value: stats.unsubscribed, color: "text-red-600" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white border border-[var(--admin-border)] rounded-2xl p-4 text-center">
|
||||
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<StatsGrid stats={stats} />
|
||||
|
||||
{/* Filter + refresh + pagination */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={load} disabled={loading}
|
||||
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
|
||||
{loading ? "Loading..." : "↻ Refresh"}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
{(["all", "active", "completed"] as const).map(f => (
|
||||
<button type="button" key={f} onClick={() => setFilterAndResetPage(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button type="button" onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Previous">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
|
||||
<button type="button" onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Next">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FilterBar
|
||||
loading={loading}
|
||||
filter={filter}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onLoad={load}
|
||||
onSelectFilter={(f) => dispatch({ type: "SET_FILTER", filter: f })}
|
||||
onPrevPage={() => dispatch({ type: "SET_PAGE", page: Math.max(0, page - 1) })}
|
||||
onNextPage={() => dispatch({ type: "SET_PAGE", page: Math.min(totalPages - 1, page + 1) })}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
{filtered.length === 0 ? (
|
||||
@@ -112,63 +119,151 @@ export default function WelcomeSequenceDashboard({ brandId }: Props) {
|
||||
<p className="text-[var(--admin-text-muted)] text-sm">No entries{filter !== "all" ? ` (${filter})` : ""}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-[var(--admin-border)] rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-5 py-3 font-medium">Contact</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Language</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Step</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Enrolled</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Last Email</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedEntries.map(e => {
|
||||
const meta = STATUS_LABELS[e.status] ?? { en: e.status, color: "bg-stone-100 text-stone-500 border border-stone-200" };
|
||||
return (
|
||||
<tr key={e.id} className="border-t border-[var(--admin-border)] hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div>
|
||||
<p className="text-[var(--admin-text-primary)] font-medium text-sm">{e.contact_name ?? "—"}</p>
|
||||
<p className="text-[var(--admin-text-muted)] text-xs">{e.contact_email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-xs font-mono text-[var(--admin-text-muted)] uppercase">{e.locale}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-xs text-[var(--admin-text-muted)]">
|
||||
{STEP_LABELS[e.sequence_step] ?? e.sequence_step}
|
||||
{e.next_email_at && e.status === "active" && (
|
||||
<span className="block text-[var(--admin-text-muted)] text-[10px]">Next: {new Date(e.next_email_at).toLocaleString()}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
|
||||
{meta.en}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(e.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">
|
||||
{e.last_email_sent_at ? new Date(e.last_email_sent_at).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
{e.status !== "unsubscribed" && (
|
||||
<button type="button" onClick={() => handleResend(e.id)} disabled={resending === e.id}
|
||||
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
|
||||
{resending === e.id ? "..." : "Resend"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<EntriesTable
|
||||
entries={paginatedEntries}
|
||||
resending={resending}
|
||||
onResend={handleResend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsGrid({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total", value: stats.total, color: "text-[var(--admin-text-primary)]" },
|
||||
{ label: "Active", value: stats.active, color: "text-blue-600" },
|
||||
{ label: "Completed", value: stats.completed, color: "text-green-600" },
|
||||
{ label: "Unsubscribed", value: stats.unsubscribed, color: "text-red-600" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white border border-[var(--admin-border)] rounded-2xl p-4 text-center">
|
||||
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilterBarProps = {
|
||||
loading: boolean;
|
||||
filter: Filter;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onLoad: () => void;
|
||||
onSelectFilter: (f: Filter) => void;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
};
|
||||
|
||||
function FilterBar({
|
||||
loading,
|
||||
filter,
|
||||
page,
|
||||
totalPages,
|
||||
onLoad,
|
||||
onSelectFilter,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={onLoad} disabled={loading}
|
||||
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
|
||||
{loading ? "Loading..." : "↻ Refresh"}
|
||||
</button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
{(["all", "active", "completed"] as const).map(f => (
|
||||
<button type="button" key={f} onClick={() => onSelectFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button type="button" onClick={onPrevPage} disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Previous">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
|
||||
<button type="button" onClick={onNextPage} disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Next">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntriesTable({
|
||||
entries,
|
||||
resending,
|
||||
onResend,
|
||||
}: {
|
||||
entries: WelcomeSequenceEntry[];
|
||||
resending: string | null;
|
||||
onResend: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-[var(--admin-border)] rounded-2xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-5 py-3 font-medium">Contact</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Language</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Step</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Enrolled</th>
|
||||
<th className="text-left px-5 py-3 font-medium">Last Email</th>
|
||||
<th className="text-right px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(e => {
|
||||
const meta = STATUS_LABELS[e.status] ?? { en: e.status, color: "bg-stone-100 text-stone-500 border border-stone-200" };
|
||||
return (
|
||||
<tr key={e.id} className="border-t border-[var(--admin-border)] hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div>
|
||||
<p className="text-[var(--admin-text-primary)] font-medium text-sm">{e.contact_name ?? "—"}</p>
|
||||
<p className="text-[var(--admin-text-muted)] text-xs">{e.contact_email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-xs font-mono text-[var(--admin-text-muted)] uppercase">{e.locale}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-xs text-[var(--admin-text-muted)]">
|
||||
{STEP_LABELS[e.sequence_step] ?? e.sequence_step}
|
||||
{e.next_email_at && e.status === "active" && (
|
||||
<span className="block text-[var(--admin-text-muted)] text-[10px]">Next: {new Date(e.next_email_at).toLocaleString()}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
|
||||
{meta.en}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(e.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">
|
||||
{e.last_email_sent_at ? new Date(e.last_email_sent_at).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
{e.status !== "unsubscribed" && (
|
||||
<button type="button" onClick={() => onResend(e.id)} disabled={resending === e.id}
|
||||
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
|
||||
{resending === e.id ? "..." : "Resend"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useReducer, FormEvent } from "react";
|
||||
|
||||
interface WaitlistFormProps {
|
||||
className?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type State = {
|
||||
email: string;
|
||||
name: string;
|
||||
referralSource: string;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_EMAIL"; value: string }
|
||||
| { type: "SET_NAME"; value: string }
|
||||
| { type: "SET_REFERRAL_SOURCE"; value: string }
|
||||
| { type: "SET_SUBMITTING"; value: boolean }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SUBMIT_SUCCESS" };
|
||||
|
||||
const initialState: State = {
|
||||
email: "",
|
||||
name: "",
|
||||
referralSource: "",
|
||||
isSubmitting: false,
|
||||
error: null,
|
||||
success: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_EMAIL":
|
||||
return { ...state, email: action.value };
|
||||
case "SET_NAME":
|
||||
return { ...state, name: action.value };
|
||||
case "SET_REFERRAL_SOURCE":
|
||||
return { ...state, referralSource: action.value };
|
||||
case "SET_SUBMITTING":
|
||||
return { ...state, isSubmitting: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SUBMIT_SUCCESS":
|
||||
return { ...state, email: "", name: "", referralSource: "", success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default function WaitlistForm({ className = "", onSuccess }: WaitlistFormProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [referralSource, setReferralSource] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
dispatch({ type: "SET_SUBMITTING", value: true });
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/waitlist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: email.trim(),
|
||||
name: name.trim() || undefined,
|
||||
referral_source: referralSource || undefined,
|
||||
email: state.email.trim(),
|
||||
name: state.name.trim() || undefined,
|
||||
referral_source: state.referralSource || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -37,19 +75,19 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
|
||||
throw new Error(data.error || "Failed to join waitlist");
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setEmail("");
|
||||
setName("");
|
||||
setReferralSource("");
|
||||
dispatch({ type: "SUBMIT_SUCCESS" });
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
value: err instanceof Error ? err.message : "Something went wrong",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
dispatch({ type: "SET_SUBMITTING", value: false });
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
if (state.success) {
|
||||
return (
|
||||
<div className={`bg-gradient-to-br from-[#1a4d2e]/10 to-[#2d6a4f]/5 rounded-2xl p-8 text-center border border-[#6b8f71]/20 ${className}`}>
|
||||
<div className="w-16 h-16 bg-[#1a4d2e]/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
@@ -77,8 +115,8 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
|
||||
<input aria-label="Your Name"
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={state.name}
|
||||
onChange={(e) => dispatch({ type: "SET_NAME", value: e.target.value })}
|
||||
placeholder="Your name"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
/>
|
||||
@@ -90,8 +128,8 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
|
||||
<input aria-label="You@farm.com"
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
value={state.email}
|
||||
onChange={(e) => dispatch({ type: "SET_EMAIL", value: e.target.value })}
|
||||
placeholder="you@farm.com"
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
@@ -105,8 +143,8 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
|
||||
</label>
|
||||
<select aria-label="Referral"
|
||||
id="referral"
|
||||
value={referralSource}
|
||||
onChange={(e) => setReferralSource(e.target.value)}
|
||||
value={state.referralSource}
|
||||
onChange={(e) => dispatch({ type: "SET_REFERRAL_SOURCE", value: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
>
|
||||
<option value="">Select an option</option>
|
||||
@@ -120,18 +158,18 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{state.error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 text-sm">
|
||||
{error}
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email}
|
||||
disabled={state.isSubmitting || !state.email}
|
||||
className="w-full px-6 py-4 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-semibold text-lg hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-[#1a4d2e]/20"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
{state.isSubmitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
@@ -149,4 +187,4 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import { searchHarvestLots, HarvestLot } from "@/actions/route-trace/lots";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import Link from "next/link";
|
||||
@@ -38,30 +38,64 @@ const Icons = {
|
||||
),
|
||||
};
|
||||
|
||||
type State = {
|
||||
query: string;
|
||||
results: HarvestLot[];
|
||||
searched: boolean;
|
||||
loading: boolean;
|
||||
showScanModal: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_QUERY"; value: string }
|
||||
| { type: "SET_RESULTS"; results: HarvestLot[] }
|
||||
| { type: "SET_LOADING"; value: boolean }
|
||||
| { type: "SET_SEARCHED"; value: boolean }
|
||||
| { type: "SET_SHOW_SCAN_MODAL"; value: boolean };
|
||||
|
||||
const initialState: State = {
|
||||
query: "",
|
||||
results: [],
|
||||
searched: false,
|
||||
loading: false,
|
||||
showScanModal: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_QUERY":
|
||||
return { ...state, query: action.value };
|
||||
case "SET_RESULTS":
|
||||
return { ...state, results: action.results };
|
||||
case "SET_LOADING":
|
||||
return { ...state, loading: action.value };
|
||||
case "SET_SEARCHED":
|
||||
return { ...state, searched: action.value };
|
||||
case "SET_SHOW_SCAN_MODAL":
|
||||
return { ...state, showScanModal: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<HarvestLot[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showScanModal, setShowScanModal] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
const res = await searchHarvestLots(brandId, query.trim());
|
||||
setResults(res.success ? res.lots : []);
|
||||
setSearched(true);
|
||||
setLoading(false);
|
||||
if (!state.query.trim()) return;
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
const res = await searchHarvestLots(brandId, state.query.trim());
|
||||
dispatch({ type: "SET_RESULTS", results: res.success ? res.lots : [] });
|
||||
dispatch({ type: "SET_SEARCHED", value: true });
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
}
|
||||
|
||||
function handleScanResult(lotNumber: string) {
|
||||
setQuery(lotNumber);
|
||||
setLoading(true);
|
||||
dispatch({ type: "SET_QUERY", value: lotNumber });
|
||||
dispatch({ type: "SET_LOADING", value: true });
|
||||
searchHarvestLots(brandId, lotNumber).then((res) => {
|
||||
setResults(res.success ? res.lots : []);
|
||||
setSearched(true);
|
||||
setLoading(false);
|
||||
dispatch({ type: "SET_RESULTS", results: res.success ? res.lots : [] });
|
||||
dispatch({ type: "SET_SEARCHED", value: true });
|
||||
dispatch({ type: "SET_LOADING", value: false });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,7 +103,7 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
<div className="space-y-5">
|
||||
{/* Scan button */}
|
||||
<button type="button"
|
||||
onClick={() => setShowScanModal(true)}
|
||||
onClick={() => dispatch({ type: "SET_SHOW_SCAN_MODAL", value: true })}
|
||||
className="w-full rounded-xl bg-emerald-600 px-5 py-4 text-sm font-bold text-white hover:bg-emerald-700 transition-colors flex items-center justify-center gap-2 shadow-sm"
|
||||
>
|
||||
<span className="text-white">{Icons.camera("h-5 w-5")}</span>
|
||||
@@ -77,9 +111,9 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
</button>
|
||||
|
||||
{/* QRScanModal */}
|
||||
{showScanModal && (
|
||||
{state.showScanModal && (
|
||||
<QRScanModal
|
||||
onClose={() => setShowScanModal(false)}
|
||||
onClose={() => dispatch({ type: "SET_SHOW_SCAN_MODAL", value: false })}
|
||||
onScanResult={handleScanResult}
|
||||
/>
|
||||
)}
|
||||
@@ -93,39 +127,39 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
<form onSubmit={handleSearch} className="flex gap-3 p-6">
|
||||
<input aria-label=". TC 20260519 001 Or Sweet Corn"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={state.query}
|
||||
onChange={(e) => dispatch({ type: "SET_QUERY", value: e.target.value })}
|
||||
placeholder="e.g. TC-20260519-001 or Sweet Corn"
|
||||
className="flex-1 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
disabled={state.loading || !state.query.trim()}
|
||||
className="rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading ? "Searching..." : <>{Icons.search("h-4 w-4")} Search</>}
|
||||
{state.loading ? "Searching..." : <>{Icons.search("h-4 w-4")} Search</>}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{searched && (
|
||||
{state.searched && (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
|
||||
{results.length === 0 ? (
|
||||
{state.results.length === 0 ? (
|
||||
<div className="p-10 text-center">
|
||||
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
|
||||
<p className="text-sm text-stone-500">No lots found for "{query}"</p>
|
||||
<p className="text-sm text-stone-500">No lots found for "{state.query}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-5 py-3 bg-stone-50 border-b border-stone-100">
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
{state.results.length} result{state.results.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{results.map((lot) => (
|
||||
{state.results.map((lot) => (
|
||||
<tr key={lot.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/admin/route-trace/lots/${lot.id}`} className="font-mono text-sm font-bold text-stone-900 hover:text-blue-600">
|
||||
@@ -150,4 +184,4 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -176,220 +176,334 @@ export default function LotListTable({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Search and filters */}
|
||||
<LotsHeader
|
||||
count={filtered.length}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
|
||||
<div className="rounded-2xl border border-stone-200 bg-white">
|
||||
<div className="border-b border-stone-100 px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] shadow-sm">
|
||||
{Icons.clipboard("w-5 h-5 text-[var(--admin-accent)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-stone-900">All Lots</h2>
|
||||
<p className="text-sm text-stone-500">{filtered.length} lot{filtered.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onCreateNew}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors shadow-sm"
|
||||
aria-label="New Lot">
|
||||
{Icons.plus("h-4 w-4")}
|
||||
<span>New Lot</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button type="button"
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors flex-1 ${
|
||||
filter === f.value
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-600 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Search */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
|
||||
<input aria-label="Search Lot #, Crop, Field, Or Bin..."
|
||||
type="text"
|
||||
placeholder="Search lot #, crop, field, or bin..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-9 pr-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
|
||||
className={`rounded-xl border px-4 py-3 text-sm font-semibold transition-colors flex items-center gap-2 ${
|
||||
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
|
||||
}`}
|
||||
aria-label="Bulk">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points={showBulk ? "9 11 12 14 22 4" : "3 6 9 12 15 18"}/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Bulk {showBulk ? "On" : "Off"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<StatusFilterBar filter={filter} onChange={setFilter} />
|
||||
<SearchAndBulkToggle
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
showBulk={showBulk}
|
||||
onToggleBulk={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="rounded-2xl border-2 border-emerald-600 bg-emerald-600 px-5 py-3 flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-white">{selected.size} lot{selected.size !== 1 ? "s" : ""} selected</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button"
|
||||
onClick={handleBulkMarkLoaded}
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Mark Loaded">
|
||||
{Icons.truck("h-4 w-4")}
|
||||
<span>Mark Loaded</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleBulkMarkUsed}
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Mark as Used">
|
||||
{Icons.package("h-4 w-4")}
|
||||
<span>Mark as Used</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleBulkStickers("field")}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Field Stickers">
|
||||
{Icons.printer("h-4 w-4")}
|
||||
<span>Field Stickers</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => handleBulkStickers("shed")}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Shed Stickers">
|
||||
{Icons.printer("h-4 w-4")}
|
||||
<span>Shed Stickers</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={handleBulkExport}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Reports`}">
|
||||
{Icons.file("h-4 w-4")}
|
||||
<span>{selected.size === 1 ? "Report" : `${selected.size} Reports`}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="rounded-xl border border-white/30 px-4 py-2 text-sm font-medium text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Clear">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<BulkActionBar
|
||||
selected={selected}
|
||||
onMarkLoaded={handleBulkMarkLoaded}
|
||||
onMarkUsed={handleBulkMarkUsed}
|
||||
onFieldStickers={() => handleBulkStickers("field")}
|
||||
onShedStickers={() => handleBulkStickers("shed")}
|
||||
onExport={handleBulkExport}
|
||||
onClear={() => setSelected(new Set())}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
|
||||
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
|
||||
<p className="text-sm text-stone-500">No lots found</p>
|
||||
</div>
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100 bg-stone-50">
|
||||
{showBulk && (
|
||||
<th className="px-5 py-3.5 w-10">
|
||||
<input aria-label="Checkbox"
|
||||
type="checkbox"
|
||||
checked={selected.size === filtered.length && filtered.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{["Lot #", "Crop / Variety", "Harvest Date", "Age", "Field", "Bins", "Qty", "Status"].map((h) => (
|
||||
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-stone-400 uppercase tracking-wider">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-5 py-3.5" aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((lot) => (
|
||||
<tr key={lot.lot_id} className={`border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors ${selected.has(lot.lot_id) ? "bg-green-50" : ""}`}>
|
||||
{showBulk && (
|
||||
<td className="px-5 py-4">
|
||||
<input aria-label="Checkbox"
|
||||
type="checkbox"
|
||||
checked={selected.has(lot.lot_id)}
|
||||
onChange={() => toggleSelect(lot.lot_id)}
|
||||
className="h-4 w-4 rounded border-stone-300"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="font-mono text-sm font-black text-stone-900 hover:text-blue-600">
|
||||
{lot.lot_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="text-sm font-semibold text-stone-700">{lot.crop_type}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{(() => {
|
||||
const age = getAgeStatus(lot.harvest_date);
|
||||
return age ? (
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-bold ${age.className}`}>
|
||||
{age.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-stone-300 text-xs">—</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="text-sm text-stone-700">{lot.field_location ?? "—"}</div>
|
||||
{lot.field_block && <div className="text-xs text-stone-400">Block: {lot.field_block}</div>}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{lot.bin_id && (
|
||||
<span className="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-700">
|
||||
{lot.bin_id}
|
||||
</span>
|
||||
)}
|
||||
{lot.pallets != null && (
|
||||
<span className="inline-flex rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
|
||||
{lot.pallets} PLT
|
||||
</span>
|
||||
)}
|
||||
{!lot.bin_id && lot.pallets == null && <span className="text-stone-300 text-xs">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-stone-600">
|
||||
{lot.quantity_lbs != null ? `${lot.quantity_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—"}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge status={lot.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
|
||||
View →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<LotsTable
|
||||
lots={filtered}
|
||||
showBulk={showBulk}
|
||||
selected={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function LotsHeader({ count, onCreateNew }: { count: number; onCreateNew?: () => void }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white">
|
||||
<div className="border-b border-stone-100 px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] shadow-sm">
|
||||
{Icons.clipboard("w-5 h-5 text-[var(--admin-accent)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-stone-900">All Lots</h2>
|
||||
<p className="text-sm text-stone-500">{count} lot{count !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onCreateNew}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors shadow-sm"
|
||||
aria-label="New Lot">
|
||||
{Icons.plus("h-4 w-4")}
|
||||
<span>New Lot</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusFilterBar({ filter, onChange }: { filter: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button type="button"
|
||||
key={f.value}
|
||||
onClick={() => onChange(f.value)}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors flex-1 ${
|
||||
filter === f.value
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-600 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchAndBulkToggle({
|
||||
query,
|
||||
onQueryChange,
|
||||
showBulk,
|
||||
onToggleBulk,
|
||||
}: {
|
||||
query: string;
|
||||
onQueryChange: (v: string) => void;
|
||||
showBulk: boolean;
|
||||
onToggleBulk: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
|
||||
<input aria-label="Search Lot #, Crop, Field, Or Bin..."
|
||||
type="text"
|
||||
placeholder="Search lot #, crop, field, or bin..."
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-9 pr-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onToggleBulk}
|
||||
className={`rounded-xl border px-4 py-3 text-sm font-semibold transition-colors flex items-center gap-2 ${
|
||||
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
|
||||
}`}
|
||||
aria-label="Bulk">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points={showBulk ? "9 11 12 14 22 4" : "3 6 9 12 15 18"}/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Bulk {showBulk ? "On" : "Off"}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkActionBar({
|
||||
selected,
|
||||
onMarkLoaded,
|
||||
onMarkUsed,
|
||||
onFieldStickers,
|
||||
onShedStickers,
|
||||
onExport,
|
||||
onClear,
|
||||
}: {
|
||||
selected: Set<string>;
|
||||
onMarkLoaded: () => void;
|
||||
onMarkUsed: () => void;
|
||||
onFieldStickers: () => void;
|
||||
onShedStickers: () => void;
|
||||
onExport: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
if (selected.size === 0) return null;
|
||||
const count = selected.size;
|
||||
return (
|
||||
<div className="rounded-2xl border-2 border-emerald-600 bg-emerald-600 px-5 py-3 flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-white">{count} lot{count !== 1 ? "s" : ""} selected</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button"
|
||||
onClick={onMarkLoaded}
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Mark Loaded">
|
||||
{Icons.truck("h-4 w-4")}
|
||||
<span>Mark Loaded</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onMarkUsed}
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Mark as Used">
|
||||
{Icons.package("h-4 w-4")}
|
||||
<span>Mark as Used</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onFieldStickers}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Field Stickers">
|
||||
{Icons.printer("h-4 w-4")}
|
||||
<span>Field Stickers</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onShedStickers}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Shed Stickers">
|
||||
{Icons.printer("h-4 w-4")}
|
||||
<span>Shed Stickers</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onExport}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
aria-label="Reports`}">
|
||||
{Icons.file("h-4 w-4")}
|
||||
<span>{count === 1 ? "Report" : `${count} Reports`}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-xl border border-white/30 px-4 py-2 text-sm font-medium text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Clear">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
|
||||
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
|
||||
<p className="text-sm text-stone-500">No lots found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LotsTable({
|
||||
lots,
|
||||
showBulk,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
}: {
|
||||
lots: HaulingLot[];
|
||||
showBulk: boolean;
|
||||
selected: Set<string>;
|
||||
onToggleSelect: (id: string) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100 bg-stone-50">
|
||||
{showBulk && (
|
||||
<th className="px-5 py-3.5 w-10">
|
||||
<input aria-label="Checkbox"
|
||||
type="checkbox"
|
||||
checked={selected.size === lots.length && lots.length > 0}
|
||||
onChange={onToggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{["Lot #", "Crop / Variety", "Harvest Date", "Age", "Field", "Bins", "Qty", "Status"].map((h) => (
|
||||
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-stone-400 uppercase tracking-wider">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-5 py-3.5" aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lots.map((lot) => (
|
||||
<LotRow
|
||||
key={lot.lot_id}
|
||||
lot={lot}
|
||||
showBulk={showBulk}
|
||||
isSelected={selected.has(lot.lot_id)}
|
||||
onToggleSelect={() => onToggleSelect(lot.lot_id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LotRow({
|
||||
lot,
|
||||
showBulk,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
lot: HaulingLot;
|
||||
showBulk: boolean;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
}) {
|
||||
const age = getAgeStatus(lot.harvest_date);
|
||||
return (
|
||||
<tr className={`border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors ${isSelected ? "bg-green-50" : ""}`}>
|
||||
{showBulk && (
|
||||
<td className="px-5 py-4">
|
||||
<input aria-label="Checkbox"
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={onToggleSelect}
|
||||
className="h-4 w-4 rounded border-stone-300"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="font-mono text-sm font-black text-stone-900 hover:text-blue-600">
|
||||
{lot.lot_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="text-sm font-semibold text-stone-700">{lot.crop_type}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{age ? (
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-bold ${age.className}`}>
|
||||
{age.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-stone-300 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="text-sm text-stone-700">{lot.field_location ?? "—"}</div>
|
||||
{lot.field_block && <div className="text-xs text-stone-400">Block: {lot.field_block}</div>}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{lot.bin_id && (
|
||||
<span className="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-700">
|
||||
{lot.bin_id}
|
||||
</span>
|
||||
)}
|
||||
{lot.pallets != null && (
|
||||
<span className="inline-flex rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
|
||||
{lot.pallets} PLT
|
||||
</span>
|
||||
)}
|
||||
{!lot.bin_id && lot.pallets == null && <span className="text-stone-300 text-xs">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-stone-600">
|
||||
{lot.quantity_lbs != null ? `${lot.quantity_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—"}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge status={lot.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
|
||||
View →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user