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:
Tyler
2026-06-26 18:55:46 -06:00
parent fdeb2ffd7f
commit fe78645609
111 changed files with 40579 additions and 23712 deletions
+206
View File
@@ -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
+732
View File
@@ -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.
+13
View File
@@ -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
│ ◠ ◠ │ ██████████████████████████████████████████████████
│ ▽ │ 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!
View File
+749
View File
@@ -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)
+14
View File
@@ -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
+17
View File
@@ -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
+2 -27
View File
@@ -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"
+82 -37
View File
@@ -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,16 +76,12 @@ 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 (
@@ -55,11 +100,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
Manage your profile information and preferences.
</p>
{error && (
{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)"
}}>{error}</div>
}}>{state.error}</div>
)}
{/* Profile card */}
@@ -88,9 +133,9 @@ 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)",
@@ -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,7 +202,7 @@ 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)",
@@ -168,11 +213,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
</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>
@@ -190,12 +235,12 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
Current: <span className="font-medium" style={{ color: "var(--admin-text-primary)" }}>{currentUser.email}</span>
</p>
{emailChangeSent ? (
{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 && (
{state.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>
)}
<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>
)}
+430 -350
View File
@@ -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>
);
+92 -41
View File
@@ -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>
+85 -35
View File
@@ -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>
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}>
@@ -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 &amp; 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
+179 -49
View File
@@ -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&apos;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"
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}
+404
View File
@@ -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>
);
}
+140
View File
@@ -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>
);
}
+364
View File
@@ -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>
);
}
+194
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+316
View File
@@ -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="[{&quot;min Qty&quot;:1,&quot;max Qty&quot;:24,&quot;price&quot;:20},{&quot;min Qty&quot;:25,&quot;max Qty&quot;:0,&quot;price&quot;: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>
);
}
+314
View File
@@ -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&apos;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>
);
}
+25
View File
@@ -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 ?? "",
};
}
+83
View File
@@ -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
View File
@@ -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,308 +175,50 @@ 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>
@@ -430,3 +226,419 @@ export default function CartClient() {
</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
View File
@@ -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 Stripes 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&apos;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>
);
}
+74 -28
View File
@@ -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());
dispatch({ type: "SET_ADMIN", value: !!(await getCurrentAdminUser()) });
} catch { /* not logged in */ }
}
load();
@@ -49,10 +95,10 @@ export default function IndianRiverAboutPage() {
<StorefrontHeader
brandName="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"
/>
@@ -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 */}
@@ -170,12 +216,12 @@ export default function IndianRiverAboutPage() {
<StorefrontFooter
brandName="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>
@@ -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>
);
}
+495 -395
View File
@@ -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";
@@ -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,11 +73,51 @@ 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
delay = 0,
}: {
children: React.ReactNode;
className?: string;
@@ -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">
&quot;{t.text}&quot;
</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">
&quot;{t.text}&quot;
</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>
);
}
+74 -35
View File
@@ -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>
+2 -2
View File
@@ -419,9 +419,9 @@ function Testimonials() {
</h2>
<p className="text-slate-500">
We&apos;re in early access with produce brands across the country. Customer stories coming soon &nbsp;
<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>
&nbsp;if you&apos;d like to be a reference customer.
</p>
</div>
+61 -27
View File
@@ -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
View File
@@ -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&#39;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&apos;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&apos;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&#39;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&apos;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&apos;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">
@@ -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>
File diff suppressed because it is too large Load Diff
+277 -167
View File
@@ -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 12 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 12 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&apos;ll review and respond within 12 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?{" "}
+128 -35
View File
@@ -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"
+256 -135
View File
@@ -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
+426 -243
View File
@@ -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>
);
}
+226 -171
View File
@@ -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,40 +69,12 @@ 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 ? (
<>
@@ -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>
{/* 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>
}
/>
</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>
{/* 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} />
<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>
);
}
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>
);
}
type TablesRowProps = {
topProducts: ProductPerformance[];
recentOrders: RecentOrder[];
};
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,9 +202,9 @@ 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>
+513 -290
View File
@@ -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>
+404 -234
View File
@@ -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>
);
}
+491 -316
View File
@@ -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>
);
}
+479 -314
View File
@@ -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>
);
}
+104 -26
View File
@@ -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 ? (
+474 -313
View File
@@ -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);
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";
@@ -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}
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>
+365 -177
View File
@@ -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>
);
}
+344 -230
View File
@@ -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";
@@ -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>
);
}
+256 -138
View File
@@ -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
+410 -283
View File
@@ -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,86 +25,119 @@ 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}`);
@@ -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>
);
}
+507 -315
View File
@@ -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>
);
};
}
+400 -231
View File
@@ -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
+338 -210
View File
@@ -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&apos;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
+254 -141
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+435 -291
View File
@@ -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
+463 -294
View File
@@ -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
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)"
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}>
@@ -280,7 +437,7 @@ function ShippingSettingsFormBody({
style={{
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderColor: "rgba(16, 185, 129, 0.3)",
color: "var(--admin-success)"
color: "var(--admin-success)",
}}
>
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -295,7 +452,7 @@ function ShippingSettingsFormBody({
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)"
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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AdminInput
label="API Secret"
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"}
/>
</div>
{/* Test connection */}
<div className="flex items-center gap-4 pt-2">
<AdminButton
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testing || !fedexApiKey || !fedexApiSecret}
>
{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>
)}
</div>
</div>
{/* Default shipping options */}
<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>
<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}
<AdminTextInput
value={fedexAccountNumber}
onChange={(e) => onFieldChange("fedexAccountNumber", e.target.value)}
placeholder="000000000000"
maxLength={12}
/>
</AdminInput>
</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.)"
label="API Key"
required
error={errors.fedexApiKey}
helpText="From FedEx Developer Portal"
>
<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>
<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."
<AdminTextInput
value={fedexApiKey}
onChange={(e) => onFieldChange("fedexApiKey", e.target.value)}
placeholder="Your FedEx API key"
/>
</AdminInput>
</div>
{/* Save/Cancel buttons */}
<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)" }}
>
{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) => onFieldChange("fedexUseProduction", checked)}
label="Use Production Mode"
description={fedexUseProduction ? "Live rates, real labels" : "Sandbox/test mode — safe for testing"}
/>
</div>
{/* Test connection */}
<div className="flex items-center gap-4 pt-2">
<AdminButton
variant="secondary"
size="sm"
onClick={onTestConnection}
disabled={testing || !fedexApiKey || !fedexApiSecret}
>
{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>
);
}
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>
<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>
</>
);
}
+167 -90
View File
@@ -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:
@@ -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>
);
}
+398 -232
View File
@@ -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>
);
}
+492 -247
View File
@@ -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({
@@ -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();
@@ -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"
>
+337 -211
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+100 -47
View File
@@ -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>
+213 -118
View File
@@ -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>
);
}
+67 -29
View File
@@ -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" />
+63 -29
View File
@@ -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 &quot;{query}&quot;</p>
<p className="text-sm text-stone-500">No lots found for &quot;{state.query}&quot;</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">
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
+319 -205
View File
@@ -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