From 53a967146149ce2a4e0c967c579556fc9dd710a3 Mon Sep 17 00:00:00 2001 From: default Date: Mon, 1 Jun 2026 19:40:55 +0000 Subject: [PATCH] Initial commit - Route Commerce platform --- .gitignore | 44 + .gitmodules | 1 + .../page-2026-05-25T23-19-03-686Z.yml | 55 + .../page-2026-05-25T23-19-36-520Z.yml | 55 + .../page-2026-05-25T23-25-08-906Z.yml | 4 + .../page-2026-05-25T23-25-17-649Z.yml | 54 + .../page-2026-05-25T23-25-23-634Z.yml | 49 + .../page-2026-05-25T23-25-53-663Z.yml | 3 + .../page-2026-05-25T23-26-18-545Z.yml | 53 + .../page-2026-05-25T23-26-24-537Z.yml | 53 + .../page-2026-05-25T23-46-05-582Z.yml | 54 + .../page-2026-05-25T23-46-35-195Z.yml | 55 + .../page-2026-05-25T23-46-57-871Z.png | Bin 0 -> 42658 bytes .../page-2026-05-25T23-49-24-803Z.yml | 4 + .../page-2026-05-26T02-09-06-979Z.yml | 51 + .../page-2026-05-26T05-09-04-342Z.yml | 42 + .../page-2026-05-26T05-09-16-533Z.yml | 45 + .../page-2026-05-26T05-09-26-150Z.yml | 45 + .../page-2026-05-26T05-09-36-552Z.yml | 168 + .../page-2026-05-26T05-09-46-053Z.yml | 45 + .../page-2026-05-26T05-09-54-824Z.yml | 30 + .../page-2026-05-26T05-10-32-126Z.yml | 9 + .../page-2026-05-26T05-11-15-363Z.yml | 95 + .../page-2026-05-26T05-11-26-680Z.yml | 28 + .../page-2026-05-26T05-11-36-883Z.yml | 45 + .../page-2026-05-26T05-11-47-458Z.yml | 132 + .../page-2026-05-26T05-11-53-269Z.yml | 120 + .../page-2026-05-26T05-12-06-752Z.yml | 45 + .../page-2026-05-27T16-13-29-423Z.yml | 45 + .../page-2026-05-27T16-13-39-159Z.yml | 1 + .../page-2026-05-27T16-13-51-469Z.yml | 45 + .../page-2026-05-27T16-17-43-255Z.yml | 45 + .../page-2026-05-27T18-33-53-522Z.yml | 49 + .../page-2026-05-27T18-33-56-314Z.yml | 50 + .../page-2026-05-27T18-33-58-591Z.yml | 50 + .../page-2026-05-27T18-34-11-874Z.yml | 3 + .../page-2026-05-27T18-34-30-126Z.yml | 4 + .../page-2026-05-27T18-34-48-461Z.yml | 3 + .../page-2026-05-27T18-35-18-327Z.yml | 42 + .../page-2026-05-27T18-35-25-016Z.yml | 185 + .../page-2026-05-27T18-35-31-386Z.yml | 182 + .../page-2026-05-27T18-36-14-195Z.yml | 120 + .../page-2026-05-27T18-36-27-792Z.yml | 10 + .../page-2026-05-27T18-36-55-812Z.yml | 214 + .../page-2026-05-27T18-39-38-878Z.yml | 4 + .../page-2026-05-27T18-39-42-252Z.yml | 182 + .../page-2026-05-27T18-39-47-481Z.yml | 4 + .../page-2026-05-27T18-42-55-676Z.yml | 55 + .../page-2026-05-27T18-43-59-858Z.yml | 42 + CLAUDE.md | 242 + ENVIRONMENT.md | 156 + PRODUCTION_DEPLOYMENT_CHECKLIST.md | 354 ++ README.md | 322 ++ debug-auth-test.ts | 53 + .../plans/2025-01-14-admin-redesign-plan.md | 399 ++ .../2026-01-09-indian-river-data-migration.md | 256 + .../plans/2026-05-23-login-flow-rebuild.md | 437 ++ .../2026-05-23-login-production-failure.md | 270 + .../specs/2025-01-14-admin-redesign-design.md | 172 + eslint.config.mjs | 18 + fix-agents.js | 9 + middleware.ts | 64 + netlify.toml | 8 + next.config.ts | 28 + package.json | 52 + playwright.config.ts | 24 + postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/nav-test.html | 19 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + scripts/import-woo-to-route.ts | 262 + scripts/seed.sh | 343 ++ scripts/upload-tuxedo-video.mjs | 74 + src/actions/admin-user.ts | 7 + src/actions/admin/audit.ts | 64 + src/actions/admin/force-login.ts | 63 + src/actions/admin/password.ts | 30 + src/actions/admin/reset-admin.ts | 51 + src/actions/admin/users.ts | 672 +++ src/actions/ai-import.ts | 459 ++ src/actions/audit.ts | 76 + src/actions/billing/retail-checkout.ts | 62 + src/actions/billing/stripe-checkout.ts | 172 + src/actions/billing/stripe-portal.ts | 147 + src/actions/brand-settings.ts | 396 ++ src/actions/checkout.ts | 261 + src/actions/communications/campaigns.ts | 184 + src/actions/communications/contacts.ts | 457 ++ src/actions/communications/segments.ts | 116 + src/actions/communications/send.ts | 149 + src/actions/communications/settings.ts | 85 + src/actions/communications/stop-blast.ts | 56 + src/actions/communications/templates.ts | 130 + .../email-automation/abandoned-cart.ts | 310 ++ .../email-automation/welcome-sequence.ts | 280 + src/actions/harvest-reach/campaigns.ts | 102 + src/actions/harvest-reach/products.ts | 34 + src/actions/harvest-reach/segments.ts | 201 + src/actions/harvest-reach/stops.ts | 43 + src/actions/import-orders.ts | 64 + src/actions/import-products.ts | 44 + src/actions/integrations/ai-providers.ts | 255 + src/actions/integrations/credentials.ts | 232 + src/actions/login.ts | 56 + src/actions/orders.ts | 237 + src/actions/orders/create-refund.ts | 58 + src/actions/orders/update-order.ts | 128 + src/actions/payments.ts | 99 + src/actions/pickup.ts | 146 + src/actions/platform/command-center.ts | 179 + src/actions/products.ts | 77 + src/actions/products/create-product.ts | 86 + src/actions/products/update-product.ts | 68 + src/actions/products/upload-image.ts | 94 + src/actions/reports.ts | 159 + src/actions/route-trace/lots.ts | 466 ++ src/actions/route-trace/types.ts | 157 + src/actions/settings/features.ts | 51 + src/actions/shipping.ts | 90 + src/actions/shipping/fedex-labels.ts | 380 ++ src/actions/shipping/fedex-rates.ts | 349 ++ src/actions/shipping/settings.ts | 257 + src/actions/square-inventory.ts | 216 + src/actions/square-orders.ts | 174 + src/actions/square-products.ts | 304 ++ src/actions/square-sync-ui.ts | 84 + src/actions/stops.ts | 90 + src/actions/stops/create-stop.ts | 71 + src/actions/stops/update-stop.ts | 78 + src/actions/tax.ts | 123 + src/actions/time-tracking/field.ts | 302 ++ src/actions/time-tracking/index.ts | 553 ++ src/actions/time-tracking/notifications.ts | 48 + src/actions/water-log/admin.ts | 499 ++ src/actions/water-log/field.ts | 255 + src/actions/water-log/settings.ts | 122 + src/actions/wholesale-auth.ts | 132 + src/actions/wholesale-register.ts | 435 ++ src/actions/wholesale.ts | 1055 ++++ src/app/admin/command-center/page.tsx | 18 + .../communications/abandoned-carts/page.tsx | 53 + .../admin/communications/analytics/page.tsx | 26 + .../communications/campaigns/[id]/page.tsx | 45 + src/app/admin/communications/compose/page.tsx | 37 + .../admin/communications/contacts/page.tsx | 42 + src/app/admin/communications/logs/page.tsx | 41 + src/app/admin/communications/page.tsx | 50 + .../admin/communications/segments/page.tsx | 27 + .../admin/communications/settings/page.tsx | 41 + .../communications/templates/[id]/page.tsx | 39 + .../admin/communications/templates/page.tsx | 36 + .../communications/welcome-sequence/page.tsx | 41 + src/app/admin/debug-auth/page.tsx | 60 + src/app/admin/error.tsx | 52 + src/app/admin/import/ImportCenterClient.tsx | 434 ++ src/app/admin/import/page.tsx | 39 + src/app/admin/layout.tsx | 32 + src/app/admin/loading.tsx | 28 + src/app/admin/me/AdminMeClient.tsx | 216 + src/app/admin/me/page.tsx | 19 + src/app/admin/orders/[id]/page.tsx | 222 + src/app/admin/orders/page.tsx | 40 + src/app/admin/page.tsx | 389 ++ src/app/admin/pickup/page.tsx | 42 + src/app/admin/products/[id]/page.tsx | 127 + src/app/admin/products/import/page.tsx | 237 + src/app/admin/products/new/page.tsx | 34 + src/app/admin/products/page.tsx | 106 + src/app/admin/reports/page.tsx | 42 + src/app/admin/route-trace/lookup/page.tsx | 28 + src/app/admin/route-trace/lots/[id]/page.tsx | 51 + src/app/admin/route-trace/lots/new/page.tsx | 27 + src/app/admin/route-trace/lots/page.tsx | 41 + src/app/admin/route-trace/page.tsx | 56 + src/app/admin/route-trace/settings/page.tsx | 35 + src/app/admin/sales/import/page.tsx | 190 + src/app/admin/settings/ai/AIClient.tsx | 1482 +++++ src/app/admin/settings/ai/page.tsx | 24 + src/app/admin/settings/apps/page.tsx | 48 + .../admin/settings/billing/AddAddonButton.tsx | 34 + .../billing/AddPaymentMethodButton.tsx | 35 + .../admin/settings/billing/BillingClient.tsx | 60 + .../settings/billing/BillingClientPage.tsx | 364 ++ .../settings/billing/BillingCycleToggle.tsx | 44 + .../settings/billing/PlanUpgradeButton.tsx | 61 + .../settings/billing/RemoveAddonButton.tsx | 36 + .../settings/billing/StripePortalButton.tsx | 39 + src/app/admin/settings/billing/page.tsx | 114 + src/app/admin/settings/brand/page.tsx | 70 + .../integrations/IntegrationsClient.tsx | 631 +++ src/app/admin/settings/integrations/page.tsx | 5 + src/app/admin/settings/page.tsx | 91 + src/app/admin/settings/payments/page.tsx | 61 + src/app/admin/settings/shipping/page.tsx | 63 + .../square-sync/SquareSyncSettingsClient.tsx | 347 ++ src/app/admin/settings/square-sync/page.tsx | 30 + src/app/admin/shipping/page.tsx | 20 + src/app/admin/stops/[id]/page.tsx | 208 + src/app/admin/stops/new/page.tsx | 81 + src/app/admin/stops/page.tsx | 95 + src/app/admin/taxes/page.tsx | 73 + src/app/admin/test-auth/page.tsx | 90 + src/app/admin/test-cookies/page.tsx | 51 + src/app/admin/time-tracking/page.tsx | 31 + src/app/admin/time-tracking/settings/page.tsx | 5 + src/app/admin/users/page.tsx | 5 + src/app/admin/water-log/entries/[id]/page.tsx | 130 + .../water-log/headgates/HeadgatesManager.tsx | 537 ++ .../admin/water-log/headgates/[id]/page.tsx | 108 + src/app/admin/water-log/headgates/page.tsx | 23 + src/app/admin/water-log/page.tsx | 40 + src/app/admin/water-log/settings/page.tsx | 261 + src/app/admin/water-log/users/[id]/page.tsx | 122 + src/app/admin/wholesale/WholesaleClient.tsx | 2276 ++++++++ src/app/admin/wholesale/page.tsx | 23 + src/app/api/ai/campaign-writer/route.ts | 64 + src/app/api/ai/customer-insights/route.ts | 132 + src/app/api/ai/demand-forecast/route.ts | 82 + src/app/api/ai/pricing-advisor/route.ts | 82 + src/app/api/ai/product-writer/route.ts | 77 + src/app/api/ai/report-explainer/route.ts | 87 + src/app/api/ai/route-optimizer/route.ts | 86 + src/app/api/ai/stop-blast-advisor/route.ts | 88 + src/app/api/auth/uid/route.ts | 11 + src/app/api/cron/send-scheduled/route.ts | 35 + src/app/api/debug-admin-users/route.ts | 45 + src/app/api/debug-auth/route.ts | 48 + src/app/api/debug-cookie/route.ts | 18 + src/app/api/debug-env/route.ts | 31 + src/app/api/debug-get-admin-users/route.ts | 34 + src/app/api/debug-hello/route.ts | 9 + src/app/api/debug-me/route.ts | 80 + src/app/api/dev-login/route.ts | 21 + .../email-automation/abandoned-cart/route.ts | 118 + .../welcome-sequence/route.ts | 81 + src/app/api/force-admin/route.ts | 31 + src/app/api/forgot-password/route.ts | 36 + .../indian-river-direct/schedule-pdf/route.ts | 179 + src/app/api/integrations/ai-provider/route.ts | 56 + .../integrations/ai-provider/test/route.ts | 94 + src/app/api/login/route.ts | 47 + src/app/api/logout/route.ts | 12 + src/app/api/reports/export/route.ts | 67 + src/app/api/resend/webhook/route.ts | 125 + src/app/api/route-trace/fsma-report/route.ts | 175 + src/app/api/route-trace/sticker-pdf/route.ts | 162 + src/app/api/route-trace/trace-report/route.ts | 206 + src/app/api/set-auth-cookie/route.ts | 22 + src/app/api/square/oauth/callback/route.ts | 133 + src/app/api/square/oauth/route.ts | 51 + src/app/api/square/process-queue/route.ts | 80 + src/app/api/square/sync/route.ts | 80 + src/app/api/stops/import/route.ts | 179 + src/app/api/stripe/webhook/route.ts | 466 ++ src/app/api/supabase-test/route.ts | 43 + src/app/api/supabase/route.ts | 52 + src/app/api/time-tracking/export/route.ts | 264 + src/app/api/time-tracking/notify/route.ts | 291 + src/app/api/tuxedo/schedule-pdf/route.ts | 190 + src/app/api/water-admin-auth/route.ts | 59 + src/app/api/water-logs/export/route.ts | 65 + src/app/api/water-photo-upload/route.ts | 61 + src/app/api/water-qr-label/route.ts | 112 + src/app/api/water-qr-sheet/route.ts | 151 + src/app/api/water-qr/route.ts | 35 + src/app/api/wholesale/checkout/route.ts | 133 + .../wholesale/invoice/[orderId]/pdf/route.ts | 290 + .../api/wholesale/invoice/[orderId]/route.ts | 309 ++ src/app/api/wholesale/manifest/route.ts | 114 + .../notifications/pickup-reminder/route.ts | 95 + .../api/wholesale/notifications/send/route.ts | 195 + src/app/api/wholesale/price-sheet/route.ts | 188 + .../api/wholesale/webhooks/dispatch/route.ts | 115 + src/app/auth/callback/page.tsx | 54 + src/app/brands/page.tsx | 286 + src/app/cart/loading.tsx | 14 + src/app/cart/page.tsx | 370 ++ src/app/change-password/page.tsx | 150 + src/app/checkout/loading.tsx | 16 + src/app/checkout/page.tsx | 390 ++ src/app/checkout/success/page.tsx | 313 ++ src/app/contact/page.tsx | 246 + src/app/dev-login/page.tsx | 20 + src/app/error.tsx | 52 + src/app/globals.css | 444 ++ src/app/indian-river-direct/about/page.tsx | 184 + src/app/indian-river-direct/contact/page.tsx | 273 + src/app/indian-river-direct/error.tsx | 27 + src/app/indian-river-direct/faq/page.tsx | 230 + src/app/indian-river-direct/layout.tsx | 25 + src/app/indian-river-direct/loading.tsx | 19 + src/app/indian-river-direct/page.tsx | 510 ++ .../stops/[slug]/loading.tsx | 18 + .../indian-river-direct/stops/[slug]/page.tsx | 235 + src/app/ird/time-clock/page.tsx | 19 + src/app/layout.tsx | 22 + src/app/login/page.tsx | 381 ++ src/app/login2/page.tsx | 105 + src/app/logout/page.tsx | 41 + src/app/not-found.tsx | 33 + src/app/page.tsx | 24 + src/app/pricing/PricingClientPage.tsx | 360 ++ src/app/pricing/page.tsx | 11 + src/app/privacy-policy/page.tsx | 113 + src/app/reset-password/page.tsx | 144 + src/app/terms-and-conditions/page.tsx | 115 + src/app/test-simple.html | 1 + src/app/test/page.tsx | 21 + src/app/trace/[lotNumber]/page.tsx | 359 ++ src/app/tuxedo/about/page.tsx | 188 + src/app/tuxedo/contact/page.tsx | 228 + src/app/tuxedo/error.tsx | 27 + src/app/tuxedo/faq/page.tsx | 234 + src/app/tuxedo/layout.tsx | 24 + src/app/tuxedo/loading.tsx | 18 + src/app/tuxedo/page.tsx | 691 +++ src/app/tuxedo/stops/[slug]/loading.tsx | 18 + src/app/tuxedo/stops/[slug]/page.tsx | 225 + src/app/tuxedo/time-clock/page.tsx | 19 + .../water/admin/login/WaterAdminPinClient.tsx | 65 + src/app/water/admin/login/page.tsx | 20 + src/app/water/admin/page.tsx | 15 + src/app/water/loading.tsx | 17 + src/app/water/page.tsx | 5 + src/app/wholesale/employee/page.tsx | 446 ++ src/app/wholesale/login/page.tsx | 172 + src/app/wholesale/payment/cancel/page.tsx | 35 + src/app/wholesale/payment/success/page.tsx | 75 + src/app/wholesale/portal/page.tsx | 522 ++ src/app/wholesale/register/page.tsx | 213 + src/components/Providers.tsx | 33 + src/components/about/CTASection.tsx | 52 + src/components/about/ContactSection.tsx | 89 + .../about/FamilyTimelineSection.tsx | 115 + .../about/IndianRiverCTASection.tsx | 80 + .../about/IndianRiverCitrusSection.tsx | 216 + .../about/IndianRiverContactSection.tsx | 160 + .../about/IndianRiverMissionSection.tsx | 90 + .../about/IndianRiverTeamSection.tsx | 67 + .../about/IndianRiverValuesSection.tsx | 99 + src/components/about/MissionSection.tsx | 76 + src/components/admin/AIProviderPanel.tsx | 280 + .../admin/AbandonedCartDashboard.tsx | 252 + src/components/admin/AdminAccessDenied.tsx | 43 + src/components/admin/AdminHeader.tsx | 220 + src/components/admin/AdminOrdersPanel.tsx | 373 ++ src/components/admin/AdminSidebar.tsx | 300 ++ src/components/admin/BrandFeatureCards.tsx | 117 + src/components/admin/BrandSettingsForm.tsx | 989 ++++ src/components/admin/CampaignListPanel.tsx | 498 ++ src/components/admin/CartHydration.tsx | 18 + .../admin/CommandCenterDashboard.tsx | 621 +++ .../admin/CommunicationSettingsForm.tsx | 122 + src/components/admin/CommunicationsNav.tsx | 43 + src/components/admin/CommunicationsPage.tsx | 115 + src/components/admin/ContactImportForm.tsx | 621 +++ src/components/admin/ContactListPanel.tsx | 265 + src/components/admin/DriverPickupPanel.tsx | 419 ++ .../admin/HarvestReach/AnalyticsDashboard.tsx | 143 + .../HarvestReach/CampaignComposerPage.tsx | 505 ++ .../admin/HarvestReach/HarvestReachNav.tsx | 35 + .../HarvestReach/MatchingCustomersPanel.tsx | 146 + .../admin/HarvestReach/SegmentBuilderPage.tsx | 118 + .../HarvestReach/SegmentBuilderPanel.tsx | 343 ++ .../admin/HarvestReach/SegmentEditModal.tsx | 81 + .../admin/HarvestReach/SegmentListSidebar.tsx | 100 + src/components/admin/HeadgateEditForm.tsx | 120 + src/components/admin/IntegrationsInner.tsx | 329 ++ .../admin/MessageCustomersSection.tsx | 309 ++ src/components/admin/MessageLogPanel.tsx | 194 + src/components/admin/NewProductForm.tsx | 314 ++ src/components/admin/NewStopForm.tsx | 220 + src/components/admin/OrderEditForm.tsx | 440 ++ src/components/admin/OrderPaymentSection.tsx | 268 + src/components/admin/OrderPickupAction.tsx | 63 + src/components/admin/OrderTableBody.tsx | 99 + src/components/admin/PaymentSettingsForm.tsx | 449 ++ .../admin/ProductAssignmentForm.tsx | 171 + src/components/admin/ProductEditForm.tsx | 335 ++ src/components/admin/ProductFilterBar.tsx | 50 + src/components/admin/ProductSyncBanner.tsx | 101 + src/components/admin/ProductTableBody.tsx | 244 + src/components/admin/ProductTableClient.tsx | 285 + src/components/admin/ReportsDashboard.tsx | 729 +++ src/components/admin/ScheduleImportModal.tsx | 380 ++ src/components/admin/SettingsSections.tsx | 678 +++ .../admin/ShippingFulfillmentPanel.tsx | 517 ++ src/components/admin/ShippingSettingsForm.tsx | 361 ++ src/components/admin/SquareSyncWidget.tsx | 230 + .../admin/SquareSyncWidgetWrapper.tsx | 9 + src/components/admin/StopEditForm.tsx | 228 + src/components/admin/StopMessagingForm.tsx | 256 + .../admin/StopProductAssignment.tsx | 238 + src/components/admin/StopTableBody.tsx | 58 + src/components/admin/StopTableClient.tsx | 322 ++ src/components/admin/StopsHeaderActions.tsx | 45 + src/components/admin/TaxDashboard.tsx | 461 ++ src/components/admin/TaxQuarterlySummary.tsx | 99 + src/components/admin/TemplateEditForm.tsx | 446 ++ .../admin/TimeTrackingAdminPanel.tsx | 338 ++ .../admin/TimeTrackingSettingsClient.tsx | 766 +++ src/components/admin/UsersPage.tsx | 656 +++ src/components/admin/WaterLogAdminPanel.tsx | 758 +++ .../admin/WaterLogEntryEditForm.tsx | 153 + src/components/admin/WaterUserEditForm.tsx | 170 + src/components/admin/WebhookLogsSection.tsx | 122 + .../admin/WelcomeSequenceDashboard.tsx | 171 + src/components/admin/shared/DataTable.tsx | 123 + src/components/admin/shared/FilterBar.tsx | 109 + src/components/admin/shared/PageHeader.tsx | 42 + src/components/admin/shared/StatusBadge.tsx | 51 + src/components/admin/shared/index.ts | 4 + src/components/cart/CartRestoredToast.tsx | 41 + src/components/landing/FeaturesAndStats.tsx | 690 +++ src/components/landing/HeroSection.tsx | 773 +++ src/components/landing/LandingPageWrapper.tsx | 430 ++ src/components/landing/TestimonialsAndCTA.tsx | 672 +++ src/components/layout/LayoutContainer.tsx | 15 + src/components/layout/SiteFooter.tsx | 31 + src/components/layout/SiteHeader.tsx | 56 + .../route-trace/AdminLookupPage.tsx | 143 + .../route-trace/FsmaReportModal.tsx | 91 + src/components/route-trace/LotCreateForm.tsx | 291 + src/components/route-trace/LotDetailPanel.tsx | 550 ++ src/components/route-trace/LotListTable.tsx | 290 + .../route-trace/PublicTraceActions.tsx | 12 + src/components/route-trace/QRScanModal.tsx | 261 + .../route-trace/QuickNewLotDrawer.tsx | 405 ++ .../route-trace/RouteTraceDashboard.tsx | 660 +++ src/components/route-trace/RouteTraceNav.tsx | 37 + .../route-trace/ShareTraceButton.tsx | 47 + src/components/route-trace/StatusBadge.tsx | 21 + .../route-trace/StickerPreviewModal.tsx | 275 + src/components/shared/EmptyState.tsx | 27 + src/components/shared/LoadingSkeleton.tsx | 117 + src/components/shared/SectionHeader.tsx | 26 + src/components/shared/ThemeToggle.tsx | 37 + .../storefront/BrandStylesProvider.tsx | 29 + src/components/storefront/CartToast.tsx | 55 + src/components/storefront/HeroSection.tsx | 179 + .../storefront/IndianRiverGrainOverlay.tsx | 21 + .../storefront/IndianRiverPaginatedStops.tsx | 179 + src/components/storefront/PaginatedStops.tsx | 180 + src/components/storefront/ProductCard.tsx | 323 ++ src/components/storefront/StopCard.tsx | 67 + src/components/storefront/StopSetEffect.tsx | 24 + .../storefront/StorefrontFooter.tsx | 220 + .../storefront/StorefrontHeader.tsx | 171 + src/components/storefront/TuxedoVideoHero.tsx | 247 + .../storefront/WhyIndianRiverDirect.tsx | 342 ++ src/components/storefront/ZipCodeSearch.tsx | 120 + .../time-tracking/TimeTrackingFieldClient.tsx | 933 ++++ src/components/water/WaterAdminClient.tsx | 748 +++ src/components/water/WaterFieldClient.tsx | 642 +++ src/components/wholesale/DepositModal.tsx | 101 + .../wholesale/OrderDetailsModal.tsx | 170 + .../wholesale/WholesaleBenefits.tsx | 65 + src/config/brands.ts | 37 + src/context/CartContext.tsx | 310 ++ src/lib/admin-permissions-service.ts | 60 + src/lib/admin-permissions-types.ts | 18 + src/lib/admin-permissions.ts | 128 + src/lib/ai-provider-models.ts | 14 + src/lib/billing.ts | 299 ++ src/lib/column-detector.ts | 340 ++ src/lib/csv-parser.ts | 181 + src/lib/csv-parsers.ts | 275 + src/lib/data-service.ts | 63 + src/lib/date-utils.ts | 90 + src/lib/email-service.ts | 476 ++ src/lib/email-templates.ts | 369 ++ src/lib/excel-parser.ts | 81 + src/lib/feature-flags.ts | 202 + src/lib/format-date.ts | 39 + src/lib/mock-data.ts | 199 + src/lib/pricing.ts | 202 + src/lib/reports-export.ts | 100 + src/lib/route-trace-units.ts | 11 + src/lib/supabase.ts | 264 + src/lib/supabase/server.ts | 25 + src/lib/svc-headers.ts | 16 + src/lib/water-log-reporting.ts | 193 + src/proxy.ts | 32 + src/types/index.ts | 42 + supabase/.gitignore | 8 + supabase/config.toml | 408 ++ .../migrations/001_checkout_hardening.sql | 201 + .../002_remove_broad_anon_select.sql | 10 + supabase/migrations/003_audit_logs.sql | 103 + supabase/migrations/004_admin_order_fetch.sql | 193 + supabase/migrations/005_water_log.sql | 342 ++ .../migrations/006_water_log_rpcs_fixed.sql | 263 + .../migrations/007_water_log_column_fix.sql | 232 + .../migrations/008_water_log_soft_delete.sql | 123 + .../009_water_log_entry_edit_delete.sql | 88 + .../migrations/010_water_log_unit_types.sql | 202 + .../011_water_log_consolidated_fix.sql | 289 + .../012_water_log_display_summary.sql | 103 + .../migrations/013_water_log_seed_admin.sql | 13 + .../migrations/014_water_sessions_rls.sql | 9 + .../015_water_users_security_hardening.sql | 90 + .../migrations/016_communication_center.sql | 662 +++ .../migrations/017_communication_contacts.sql | 878 +++ .../018_contact_import_metadata.sql | 230 + supabase/migrations/019_customers_table.sql | 75 + .../migrations/020_order_customer_linking.sql | 226 + .../migrations/021_shipping_only_brand.sql | 319 ++ .../migrations/022_operational_events.sql | 646 +++ .../023_cart_availability_check.sql | 56 + .../024_stop_product_assignment_rpcs.sql | 146 + .../025_fix_assignment_rpc_auth.sql | 177 + .../migrations/026_debug_assignment_rpc.sql | 101 + .../027_fix_rpc_signature_conflict.sql | 185 + .../migrations/028_fix_caller_uid_type.sql | 243 + .../migrations/029_drop_stale_overloads.sql | 261 + .../030_normalize_dev_user_caller_uid.sql | 268 + supabase/migrations/031_reports_v1_rpcs.sql | 337 ++ .../migrations/032_store_employee_role.sql | 177 + supabase/migrations/033_admin_users_crud.sql | 384 ++ .../migrations/034_password_change_flow.sql | 23 + supabase/migrations/035_admin_action_logs.sql | 106 + .../migrations/036_user_activity_logs.sql | 36 + .../037_update_admin_user_profile.sql | 334 ++ supabase/migrations/038_product_images.sql | 31 + .../migrations/039_shipping_fulfillment.sql | 8 + .../040_shipping_fulfillment_rpcs.sql | 86 + supabase/migrations/041_payment_settings.sql | 105 + .../migrations/042_product_import_rpc.sql | 130 + .../migrations/043_square_sync_settings.sql | 10 + supabase/migrations/044_square_sync_log.sql | 63 + .../045_extend_payment_settings_rpcs.sql | 53 + supabase/migrations/046_wholesale_portal.sql | 748 +++ .../migrations/047_wholesale_registration.sql | 201 + supabase/migrations/048_wholesale_enabled.sql | 62 + .../049_wholesale_invoice_settings.sql | 211 + .../050_wholesale_online_payment.sql | 78 + .../051_wholesale_customer_pricing.sql | 92 + .../migrations/052_wholesale_credit_limit.sql | 124 + .../migrations/053_wholesale_bulk_actions.sql | 93 + .../054_wholesale_email_notifications.sql | 160 + .../055_wholesale_notification_settings.sql | 154 + .../migrations/056_wholesale_price_sheet.sql | 10 + .../057_notification_recipients.sql | 123 + .../058_fix_get_wholesale_orders.sql | 162 + .../migrations/059_order_status_delete.sql | 63 + .../060_wholesale_pickup_orders.sql | 68 + supabase/migrations/061_soft_delete.sql | 167 + .../migrations/062_wholesale_webhooks.sql | 195 + .../063_auto_create_wholesale_settings.sql | 67 + .../064_seed_wholesale_settings.sql | 163 + .../065_fix_get_wholesale_products.sql | 43 + supabase/migrations/066_auto_square_sync.sql | 146 + .../migrations/067_checkout_session_id.sql | 160 + supabase/migrations/068_user_carts.sql | 67 + .../migrations/069_brand_scoping_phase2.sql | 606 +++ supabase/migrations/070_rls_policy_audit.sql | 278 + .../071_wholesale_square_sync_toggle.sql | 180 + .../migrations/072_orders_brand_scoping.sql | 218 + .../migrations/073_pickup_order_items_rpc.sql | 185 + .../migrations/074_products_soft_delete.sql | 77 + supabase/migrations/075_stops_soft_delete.sql | 82 + .../migrations/076_stops_address_cutoff.sql | 6 + supabase/migrations/077_stops_status.sql | 9 + .../078_wholesale_deposit_guard.sql | 63 + supabase/migrations/079_resend_analytics.sql | 22 + .../migrations/080_communication_segments.sql | 89 + .../migrations/081_stop_blast_campaign.sql | 135 + .../082_brand_name_in_campaign_email.sql | 74 + supabase/migrations/083_shipping_settings.sql | 99 + supabase/migrations/084_shipments.sql | 73 + supabase/migrations/085_brand_settings.sql | 154 + .../086_brand_settings_email_integration.sql | 330 ++ .../migrations/087_brand_logos_bucket.sql | 77 + supabase/migrations/088_brand_features.sql | 64 + .../089_ai_providers_custom_integrations.sql | 134 + .../migrations/090_storefront_settings.sql | 55 + supabase/migrations/091_brand_plan_tier.sql | 83 + .../migrations/092_stripe_subscriptions.sql | 74 + supabase/migrations/093_add_hero_image.sql | 170 + .../094_brand_color_customization.sql | 197 + supabase/migrations/095_tax_settings.sql | 248 + supabase/migrations/096_tax_orders_index.sql | 6 + .../097_extend_create_order_with_items.sql | 192 + .../migrations/098_products_is_taxable.sql | 53 + .../099_harvest_reach_segmentation.sql | 554 ++ .../migrations/108_lot_quantity_tracking.sql | 77 + .../migrations/109_enable_rls_critical.sql | 160 + .../migrations/110_rls_zero_policy_tables.sql | 303 ++ .../111_route_trace_recent_activity.sql | 96 + .../119_regenerate_headgate_token_brand.sql | 30 + supabase/migrations/120_water_alerts.sql | 269 + supabase/migrations/126_founder_pain_log.sql | 39 + .../127_platform_command_center_rpc.sql | 155 + ...get_water_headgates_admin_last_used_at.sql | 11 + .../migrations/129_worker_time_tracking.sql | 569 ++ .../130_time_tracking_pay_period_overtime.sql | 186 + .../133_time_tracking_notifications.sql | 349 ++ .../134_abandoned_cart_recovery.sql | 123 + .../migrations/135_email_automation_rpcs.sql | 226 + .../136_email_automation_missing_rpcs.sql | 257 + .../137_time_tracking_workers_and_tasks.sql | 193 + .../migrations/138_time_tracking_logs.sql | 70 + .../139_time_tracking_missing_rpcs.sql | 115 + .../140_time_tracking_field_rpcs.sql | 264 + .../141_update_user_password_rpc.sql | 20 + .../142_integration_credentials.sql | 85 + supabase/migrations/BUNDLE_018_042.sql | 4778 +++++++++++++++++ supabase/push-migrations.js | 141 + test-cookie.cjs | 56 + test-results/.last-run.json | 4 + tests/login/login-flow.spec.ts | 79 + tests/smoke.spec.ts | 62 + tsconfig.json | 34 + vercel.json | 61 + 617 files changed, 106132 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .playwright-mcp/page-2026-05-25T23-19-03-686Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-19-36-520Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-25-08-906Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-25-17-649Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-25-23-634Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-25-53-663Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-26-18-545Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-26-24-537Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-46-05-582Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-46-35-195Z.yml create mode 100644 .playwright-mcp/page-2026-05-25T23-46-57-871Z.png create mode 100644 .playwright-mcp/page-2026-05-25T23-49-24-803Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T02-09-06-979Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-09-04-342Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-09-16-533Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-09-26-150Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-09-36-552Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-09-46-053Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-09-54-824Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-10-32-126Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-11-15-363Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-11-26-680Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-11-36-883Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-11-47-458Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-11-53-269Z.yml create mode 100644 .playwright-mcp/page-2026-05-26T05-12-06-752Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T16-13-29-423Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T16-13-39-159Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T16-13-51-469Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T16-17-43-255Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-33-53-522Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-33-56-314Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-33-58-591Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-34-11-874Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-34-30-126Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-34-48-461Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-35-18-327Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-35-25-016Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-35-31-386Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-36-14-195Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-36-27-792Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-36-55-812Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-39-38-878Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-39-42-252Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-39-47-481Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-42-55-676Z.yml create mode 100644 .playwright-mcp/page-2026-05-27T18-43-59-858Z.yml create mode 100644 CLAUDE.md create mode 100644 ENVIRONMENT.md create mode 100644 PRODUCTION_DEPLOYMENT_CHECKLIST.md create mode 100644 README.md create mode 100644 debug-auth-test.ts create mode 100644 docs/superpowers/plans/2025-01-14-admin-redesign-plan.md create mode 100644 docs/superpowers/plans/2026-01-09-indian-river-data-migration.md create mode 100644 docs/superpowers/plans/2026-05-23-login-flow-rebuild.md create mode 100644 docs/superpowers/plans/2026-05-23-login-production-failure.md create mode 100644 docs/superpowers/specs/2025-01-14-admin-redesign-design.md create mode 100644 eslint.config.mjs create mode 100644 fix-agents.js create mode 100644 middleware.ts create mode 100644 netlify.toml create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/nav-test.html create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 scripts/import-woo-to-route.ts create mode 100644 scripts/seed.sh create mode 100644 scripts/upload-tuxedo-video.mjs create mode 100644 src/actions/admin-user.ts create mode 100644 src/actions/admin/audit.ts create mode 100644 src/actions/admin/force-login.ts create mode 100644 src/actions/admin/password.ts create mode 100644 src/actions/admin/reset-admin.ts create mode 100644 src/actions/admin/users.ts create mode 100644 src/actions/ai-import.ts create mode 100644 src/actions/audit.ts create mode 100644 src/actions/billing/retail-checkout.ts create mode 100644 src/actions/billing/stripe-checkout.ts create mode 100644 src/actions/billing/stripe-portal.ts create mode 100644 src/actions/brand-settings.ts create mode 100644 src/actions/checkout.ts create mode 100644 src/actions/communications/campaigns.ts create mode 100644 src/actions/communications/contacts.ts create mode 100644 src/actions/communications/segments.ts create mode 100644 src/actions/communications/send.ts create mode 100644 src/actions/communications/settings.ts create mode 100644 src/actions/communications/stop-blast.ts create mode 100644 src/actions/communications/templates.ts create mode 100644 src/actions/email-automation/abandoned-cart.ts create mode 100644 src/actions/email-automation/welcome-sequence.ts create mode 100644 src/actions/harvest-reach/campaigns.ts create mode 100644 src/actions/harvest-reach/products.ts create mode 100644 src/actions/harvest-reach/segments.ts create mode 100644 src/actions/harvest-reach/stops.ts create mode 100644 src/actions/import-orders.ts create mode 100644 src/actions/import-products.ts create mode 100644 src/actions/integrations/ai-providers.ts create mode 100644 src/actions/integrations/credentials.ts create mode 100644 src/actions/login.ts create mode 100644 src/actions/orders.ts create mode 100644 src/actions/orders/create-refund.ts create mode 100644 src/actions/orders/update-order.ts create mode 100644 src/actions/payments.ts create mode 100644 src/actions/pickup.ts create mode 100644 src/actions/platform/command-center.ts create mode 100644 src/actions/products.ts create mode 100644 src/actions/products/create-product.ts create mode 100644 src/actions/products/update-product.ts create mode 100644 src/actions/products/upload-image.ts create mode 100644 src/actions/reports.ts create mode 100644 src/actions/route-trace/lots.ts create mode 100644 src/actions/route-trace/types.ts create mode 100644 src/actions/settings/features.ts create mode 100644 src/actions/shipping.ts create mode 100644 src/actions/shipping/fedex-labels.ts create mode 100644 src/actions/shipping/fedex-rates.ts create mode 100644 src/actions/shipping/settings.ts create mode 100644 src/actions/square-inventory.ts create mode 100644 src/actions/square-orders.ts create mode 100644 src/actions/square-products.ts create mode 100644 src/actions/square-sync-ui.ts create mode 100644 src/actions/stops.ts create mode 100644 src/actions/stops/create-stop.ts create mode 100644 src/actions/stops/update-stop.ts create mode 100644 src/actions/tax.ts create mode 100644 src/actions/time-tracking/field.ts create mode 100644 src/actions/time-tracking/index.ts create mode 100644 src/actions/time-tracking/notifications.ts create mode 100644 src/actions/water-log/admin.ts create mode 100644 src/actions/water-log/field.ts create mode 100644 src/actions/water-log/settings.ts create mode 100644 src/actions/wholesale-auth.ts create mode 100644 src/actions/wholesale-register.ts create mode 100644 src/actions/wholesale.ts create mode 100644 src/app/admin/command-center/page.tsx create mode 100644 src/app/admin/communications/abandoned-carts/page.tsx create mode 100644 src/app/admin/communications/analytics/page.tsx create mode 100644 src/app/admin/communications/campaigns/[id]/page.tsx create mode 100644 src/app/admin/communications/compose/page.tsx create mode 100644 src/app/admin/communications/contacts/page.tsx create mode 100644 src/app/admin/communications/logs/page.tsx create mode 100644 src/app/admin/communications/page.tsx create mode 100644 src/app/admin/communications/segments/page.tsx create mode 100644 src/app/admin/communications/settings/page.tsx create mode 100644 src/app/admin/communications/templates/[id]/page.tsx create mode 100644 src/app/admin/communications/templates/page.tsx create mode 100644 src/app/admin/communications/welcome-sequence/page.tsx create mode 100644 src/app/admin/debug-auth/page.tsx create mode 100644 src/app/admin/error.tsx create mode 100644 src/app/admin/import/ImportCenterClient.tsx create mode 100644 src/app/admin/import/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/loading.tsx create mode 100644 src/app/admin/me/AdminMeClient.tsx create mode 100644 src/app/admin/me/page.tsx create mode 100644 src/app/admin/orders/[id]/page.tsx create mode 100644 src/app/admin/orders/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/pickup/page.tsx create mode 100644 src/app/admin/products/[id]/page.tsx create mode 100644 src/app/admin/products/import/page.tsx create mode 100644 src/app/admin/products/new/page.tsx create mode 100644 src/app/admin/products/page.tsx create mode 100644 src/app/admin/reports/page.tsx create mode 100644 src/app/admin/route-trace/lookup/page.tsx create mode 100644 src/app/admin/route-trace/lots/[id]/page.tsx create mode 100644 src/app/admin/route-trace/lots/new/page.tsx create mode 100644 src/app/admin/route-trace/lots/page.tsx create mode 100644 src/app/admin/route-trace/page.tsx create mode 100644 src/app/admin/route-trace/settings/page.tsx create mode 100644 src/app/admin/sales/import/page.tsx create mode 100644 src/app/admin/settings/ai/AIClient.tsx create mode 100644 src/app/admin/settings/ai/page.tsx create mode 100644 src/app/admin/settings/apps/page.tsx create mode 100644 src/app/admin/settings/billing/AddAddonButton.tsx create mode 100644 src/app/admin/settings/billing/AddPaymentMethodButton.tsx create mode 100644 src/app/admin/settings/billing/BillingClient.tsx create mode 100644 src/app/admin/settings/billing/BillingClientPage.tsx create mode 100644 src/app/admin/settings/billing/BillingCycleToggle.tsx create mode 100644 src/app/admin/settings/billing/PlanUpgradeButton.tsx create mode 100644 src/app/admin/settings/billing/RemoveAddonButton.tsx create mode 100644 src/app/admin/settings/billing/StripePortalButton.tsx create mode 100644 src/app/admin/settings/billing/page.tsx create mode 100644 src/app/admin/settings/brand/page.tsx create mode 100644 src/app/admin/settings/integrations/IntegrationsClient.tsx create mode 100644 src/app/admin/settings/integrations/page.tsx create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/app/admin/settings/payments/page.tsx create mode 100644 src/app/admin/settings/shipping/page.tsx create mode 100644 src/app/admin/settings/square-sync/SquareSyncSettingsClient.tsx create mode 100644 src/app/admin/settings/square-sync/page.tsx create mode 100644 src/app/admin/shipping/page.tsx create mode 100644 src/app/admin/stops/[id]/page.tsx create mode 100644 src/app/admin/stops/new/page.tsx create mode 100644 src/app/admin/stops/page.tsx create mode 100644 src/app/admin/taxes/page.tsx create mode 100644 src/app/admin/test-auth/page.tsx create mode 100644 src/app/admin/test-cookies/page.tsx create mode 100644 src/app/admin/time-tracking/page.tsx create mode 100644 src/app/admin/time-tracking/settings/page.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/app/admin/water-log/entries/[id]/page.tsx create mode 100644 src/app/admin/water-log/headgates/HeadgatesManager.tsx create mode 100644 src/app/admin/water-log/headgates/[id]/page.tsx create mode 100644 src/app/admin/water-log/headgates/page.tsx create mode 100644 src/app/admin/water-log/page.tsx create mode 100644 src/app/admin/water-log/settings/page.tsx create mode 100644 src/app/admin/water-log/users/[id]/page.tsx create mode 100644 src/app/admin/wholesale/WholesaleClient.tsx create mode 100644 src/app/admin/wholesale/page.tsx create mode 100644 src/app/api/ai/campaign-writer/route.ts create mode 100644 src/app/api/ai/customer-insights/route.ts create mode 100644 src/app/api/ai/demand-forecast/route.ts create mode 100644 src/app/api/ai/pricing-advisor/route.ts create mode 100644 src/app/api/ai/product-writer/route.ts create mode 100644 src/app/api/ai/report-explainer/route.ts create mode 100644 src/app/api/ai/route-optimizer/route.ts create mode 100644 src/app/api/ai/stop-blast-advisor/route.ts create mode 100644 src/app/api/auth/uid/route.ts create mode 100644 src/app/api/cron/send-scheduled/route.ts create mode 100644 src/app/api/debug-admin-users/route.ts create mode 100644 src/app/api/debug-auth/route.ts create mode 100644 src/app/api/debug-cookie/route.ts create mode 100644 src/app/api/debug-env/route.ts create mode 100644 src/app/api/debug-get-admin-users/route.ts create mode 100644 src/app/api/debug-hello/route.ts create mode 100644 src/app/api/debug-me/route.ts create mode 100644 src/app/api/dev-login/route.ts create mode 100644 src/app/api/email-automation/abandoned-cart/route.ts create mode 100644 src/app/api/email-automation/welcome-sequence/route.ts create mode 100644 src/app/api/force-admin/route.ts create mode 100644 src/app/api/forgot-password/route.ts create mode 100644 src/app/api/indian-river-direct/schedule-pdf/route.ts create mode 100644 src/app/api/integrations/ai-provider/route.ts create mode 100644 src/app/api/integrations/ai-provider/test/route.ts create mode 100644 src/app/api/login/route.ts create mode 100644 src/app/api/logout/route.ts create mode 100644 src/app/api/reports/export/route.ts create mode 100644 src/app/api/resend/webhook/route.ts create mode 100644 src/app/api/route-trace/fsma-report/route.ts create mode 100644 src/app/api/route-trace/sticker-pdf/route.ts create mode 100644 src/app/api/route-trace/trace-report/route.ts create mode 100644 src/app/api/set-auth-cookie/route.ts create mode 100644 src/app/api/square/oauth/callback/route.ts create mode 100644 src/app/api/square/oauth/route.ts create mode 100644 src/app/api/square/process-queue/route.ts create mode 100644 src/app/api/square/sync/route.ts create mode 100644 src/app/api/stops/import/route.ts create mode 100644 src/app/api/stripe/webhook/route.ts create mode 100644 src/app/api/supabase-test/route.ts create mode 100644 src/app/api/supabase/route.ts create mode 100644 src/app/api/time-tracking/export/route.ts create mode 100644 src/app/api/time-tracking/notify/route.ts create mode 100644 src/app/api/tuxedo/schedule-pdf/route.ts create mode 100644 src/app/api/water-admin-auth/route.ts create mode 100644 src/app/api/water-logs/export/route.ts create mode 100644 src/app/api/water-photo-upload/route.ts create mode 100644 src/app/api/water-qr-label/route.ts create mode 100644 src/app/api/water-qr-sheet/route.ts create mode 100644 src/app/api/water-qr/route.ts create mode 100644 src/app/api/wholesale/checkout/route.ts create mode 100644 src/app/api/wholesale/invoice/[orderId]/pdf/route.ts create mode 100644 src/app/api/wholesale/invoice/[orderId]/route.ts create mode 100644 src/app/api/wholesale/manifest/route.ts create mode 100644 src/app/api/wholesale/notifications/pickup-reminder/route.ts create mode 100644 src/app/api/wholesale/notifications/send/route.ts create mode 100644 src/app/api/wholesale/price-sheet/route.ts create mode 100644 src/app/api/wholesale/webhooks/dispatch/route.ts create mode 100644 src/app/auth/callback/page.tsx create mode 100644 src/app/brands/page.tsx create mode 100644 src/app/cart/loading.tsx create mode 100644 src/app/cart/page.tsx create mode 100644 src/app/change-password/page.tsx create mode 100644 src/app/checkout/loading.tsx create mode 100644 src/app/checkout/page.tsx create mode 100644 src/app/checkout/success/page.tsx create mode 100644 src/app/contact/page.tsx create mode 100644 src/app/dev-login/page.tsx create mode 100644 src/app/error.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/indian-river-direct/about/page.tsx create mode 100644 src/app/indian-river-direct/contact/page.tsx create mode 100644 src/app/indian-river-direct/error.tsx create mode 100644 src/app/indian-river-direct/faq/page.tsx create mode 100644 src/app/indian-river-direct/layout.tsx create mode 100644 src/app/indian-river-direct/loading.tsx create mode 100644 src/app/indian-river-direct/page.tsx create mode 100644 src/app/indian-river-direct/stops/[slug]/loading.tsx create mode 100644 src/app/indian-river-direct/stops/[slug]/page.tsx create mode 100644 src/app/ird/time-clock/page.tsx create mode 100644 src/app/layout.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/login2/page.tsx create mode 100644 src/app/logout/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/pricing/PricingClientPage.tsx create mode 100644 src/app/pricing/page.tsx create mode 100644 src/app/privacy-policy/page.tsx create mode 100644 src/app/reset-password/page.tsx create mode 100644 src/app/terms-and-conditions/page.tsx create mode 100644 src/app/test-simple.html create mode 100644 src/app/test/page.tsx create mode 100644 src/app/trace/[lotNumber]/page.tsx create mode 100644 src/app/tuxedo/about/page.tsx create mode 100644 src/app/tuxedo/contact/page.tsx create mode 100644 src/app/tuxedo/error.tsx create mode 100644 src/app/tuxedo/faq/page.tsx create mode 100644 src/app/tuxedo/layout.tsx create mode 100644 src/app/tuxedo/loading.tsx create mode 100644 src/app/tuxedo/page.tsx create mode 100644 src/app/tuxedo/stops/[slug]/loading.tsx create mode 100644 src/app/tuxedo/stops/[slug]/page.tsx create mode 100644 src/app/tuxedo/time-clock/page.tsx create mode 100644 src/app/water/admin/login/WaterAdminPinClient.tsx create mode 100644 src/app/water/admin/login/page.tsx create mode 100644 src/app/water/admin/page.tsx create mode 100644 src/app/water/loading.tsx create mode 100644 src/app/water/page.tsx create mode 100644 src/app/wholesale/employee/page.tsx create mode 100644 src/app/wholesale/login/page.tsx create mode 100644 src/app/wholesale/payment/cancel/page.tsx create mode 100644 src/app/wholesale/payment/success/page.tsx create mode 100644 src/app/wholesale/portal/page.tsx create mode 100644 src/app/wholesale/register/page.tsx create mode 100644 src/components/Providers.tsx create mode 100644 src/components/about/CTASection.tsx create mode 100644 src/components/about/ContactSection.tsx create mode 100644 src/components/about/FamilyTimelineSection.tsx create mode 100644 src/components/about/IndianRiverCTASection.tsx create mode 100644 src/components/about/IndianRiverCitrusSection.tsx create mode 100644 src/components/about/IndianRiverContactSection.tsx create mode 100644 src/components/about/IndianRiverMissionSection.tsx create mode 100644 src/components/about/IndianRiverTeamSection.tsx create mode 100644 src/components/about/IndianRiverValuesSection.tsx create mode 100644 src/components/about/MissionSection.tsx create mode 100644 src/components/admin/AIProviderPanel.tsx create mode 100644 src/components/admin/AbandonedCartDashboard.tsx create mode 100644 src/components/admin/AdminAccessDenied.tsx create mode 100644 src/components/admin/AdminHeader.tsx create mode 100644 src/components/admin/AdminOrdersPanel.tsx create mode 100644 src/components/admin/AdminSidebar.tsx create mode 100644 src/components/admin/BrandFeatureCards.tsx create mode 100644 src/components/admin/BrandSettingsForm.tsx create mode 100644 src/components/admin/CampaignListPanel.tsx create mode 100644 src/components/admin/CartHydration.tsx create mode 100644 src/components/admin/CommandCenterDashboard.tsx create mode 100644 src/components/admin/CommunicationSettingsForm.tsx create mode 100644 src/components/admin/CommunicationsNav.tsx create mode 100644 src/components/admin/CommunicationsPage.tsx create mode 100644 src/components/admin/ContactImportForm.tsx create mode 100644 src/components/admin/ContactListPanel.tsx create mode 100644 src/components/admin/DriverPickupPanel.tsx create mode 100644 src/components/admin/HarvestReach/AnalyticsDashboard.tsx create mode 100644 src/components/admin/HarvestReach/CampaignComposerPage.tsx create mode 100644 src/components/admin/HarvestReach/HarvestReachNav.tsx create mode 100644 src/components/admin/HarvestReach/MatchingCustomersPanel.tsx create mode 100644 src/components/admin/HarvestReach/SegmentBuilderPage.tsx create mode 100644 src/components/admin/HarvestReach/SegmentBuilderPanel.tsx create mode 100644 src/components/admin/HarvestReach/SegmentEditModal.tsx create mode 100644 src/components/admin/HarvestReach/SegmentListSidebar.tsx create mode 100644 src/components/admin/HeadgateEditForm.tsx create mode 100644 src/components/admin/IntegrationsInner.tsx create mode 100644 src/components/admin/MessageCustomersSection.tsx create mode 100644 src/components/admin/MessageLogPanel.tsx create mode 100644 src/components/admin/NewProductForm.tsx create mode 100644 src/components/admin/NewStopForm.tsx create mode 100644 src/components/admin/OrderEditForm.tsx create mode 100644 src/components/admin/OrderPaymentSection.tsx create mode 100644 src/components/admin/OrderPickupAction.tsx create mode 100644 src/components/admin/OrderTableBody.tsx create mode 100644 src/components/admin/PaymentSettingsForm.tsx create mode 100644 src/components/admin/ProductAssignmentForm.tsx create mode 100644 src/components/admin/ProductEditForm.tsx create mode 100644 src/components/admin/ProductFilterBar.tsx create mode 100644 src/components/admin/ProductSyncBanner.tsx create mode 100644 src/components/admin/ProductTableBody.tsx create mode 100644 src/components/admin/ProductTableClient.tsx create mode 100644 src/components/admin/ReportsDashboard.tsx create mode 100644 src/components/admin/ScheduleImportModal.tsx create mode 100644 src/components/admin/SettingsSections.tsx create mode 100644 src/components/admin/ShippingFulfillmentPanel.tsx create mode 100644 src/components/admin/ShippingSettingsForm.tsx create mode 100644 src/components/admin/SquareSyncWidget.tsx create mode 100644 src/components/admin/SquareSyncWidgetWrapper.tsx create mode 100644 src/components/admin/StopEditForm.tsx create mode 100644 src/components/admin/StopMessagingForm.tsx create mode 100644 src/components/admin/StopProductAssignment.tsx create mode 100644 src/components/admin/StopTableBody.tsx create mode 100644 src/components/admin/StopTableClient.tsx create mode 100644 src/components/admin/StopsHeaderActions.tsx create mode 100644 src/components/admin/TaxDashboard.tsx create mode 100644 src/components/admin/TaxQuarterlySummary.tsx create mode 100644 src/components/admin/TemplateEditForm.tsx create mode 100644 src/components/admin/TimeTrackingAdminPanel.tsx create mode 100644 src/components/admin/TimeTrackingSettingsClient.tsx create mode 100644 src/components/admin/UsersPage.tsx create mode 100644 src/components/admin/WaterLogAdminPanel.tsx create mode 100644 src/components/admin/WaterLogEntryEditForm.tsx create mode 100644 src/components/admin/WaterUserEditForm.tsx create mode 100644 src/components/admin/WebhookLogsSection.tsx create mode 100644 src/components/admin/WelcomeSequenceDashboard.tsx create mode 100644 src/components/admin/shared/DataTable.tsx create mode 100644 src/components/admin/shared/FilterBar.tsx create mode 100644 src/components/admin/shared/PageHeader.tsx create mode 100644 src/components/admin/shared/StatusBadge.tsx create mode 100644 src/components/admin/shared/index.ts create mode 100644 src/components/cart/CartRestoredToast.tsx create mode 100644 src/components/landing/FeaturesAndStats.tsx create mode 100644 src/components/landing/HeroSection.tsx create mode 100644 src/components/landing/LandingPageWrapper.tsx create mode 100644 src/components/landing/TestimonialsAndCTA.tsx create mode 100644 src/components/layout/LayoutContainer.tsx create mode 100644 src/components/layout/SiteFooter.tsx create mode 100644 src/components/layout/SiteHeader.tsx create mode 100644 src/components/route-trace/AdminLookupPage.tsx create mode 100644 src/components/route-trace/FsmaReportModal.tsx create mode 100644 src/components/route-trace/LotCreateForm.tsx create mode 100644 src/components/route-trace/LotDetailPanel.tsx create mode 100644 src/components/route-trace/LotListTable.tsx create mode 100644 src/components/route-trace/PublicTraceActions.tsx create mode 100644 src/components/route-trace/QRScanModal.tsx create mode 100644 src/components/route-trace/QuickNewLotDrawer.tsx create mode 100644 src/components/route-trace/RouteTraceDashboard.tsx create mode 100644 src/components/route-trace/RouteTraceNav.tsx create mode 100644 src/components/route-trace/ShareTraceButton.tsx create mode 100644 src/components/route-trace/StatusBadge.tsx create mode 100644 src/components/route-trace/StickerPreviewModal.tsx create mode 100644 src/components/shared/EmptyState.tsx create mode 100644 src/components/shared/LoadingSkeleton.tsx create mode 100644 src/components/shared/SectionHeader.tsx create mode 100644 src/components/shared/ThemeToggle.tsx create mode 100644 src/components/storefront/BrandStylesProvider.tsx create mode 100644 src/components/storefront/CartToast.tsx create mode 100644 src/components/storefront/HeroSection.tsx create mode 100644 src/components/storefront/IndianRiverGrainOverlay.tsx create mode 100644 src/components/storefront/IndianRiverPaginatedStops.tsx create mode 100644 src/components/storefront/PaginatedStops.tsx create mode 100644 src/components/storefront/ProductCard.tsx create mode 100644 src/components/storefront/StopCard.tsx create mode 100644 src/components/storefront/StopSetEffect.tsx create mode 100644 src/components/storefront/StorefrontFooter.tsx create mode 100644 src/components/storefront/StorefrontHeader.tsx create mode 100644 src/components/storefront/TuxedoVideoHero.tsx create mode 100644 src/components/storefront/WhyIndianRiverDirect.tsx create mode 100644 src/components/storefront/ZipCodeSearch.tsx create mode 100644 src/components/time-tracking/TimeTrackingFieldClient.tsx create mode 100644 src/components/water/WaterAdminClient.tsx create mode 100644 src/components/water/WaterFieldClient.tsx create mode 100644 src/components/wholesale/DepositModal.tsx create mode 100644 src/components/wholesale/OrderDetailsModal.tsx create mode 100644 src/components/wholesale/WholesaleBenefits.tsx create mode 100644 src/config/brands.ts create mode 100644 src/context/CartContext.tsx create mode 100644 src/lib/admin-permissions-service.ts create mode 100644 src/lib/admin-permissions-types.ts create mode 100644 src/lib/admin-permissions.ts create mode 100644 src/lib/ai-provider-models.ts create mode 100644 src/lib/billing.ts create mode 100644 src/lib/column-detector.ts create mode 100644 src/lib/csv-parser.ts create mode 100644 src/lib/csv-parsers.ts create mode 100644 src/lib/data-service.ts create mode 100644 src/lib/date-utils.ts create mode 100644 src/lib/email-service.ts create mode 100644 src/lib/email-templates.ts create mode 100644 src/lib/excel-parser.ts create mode 100644 src/lib/feature-flags.ts create mode 100644 src/lib/format-date.ts create mode 100644 src/lib/mock-data.ts create mode 100644 src/lib/pricing.ts create mode 100644 src/lib/reports-export.ts create mode 100644 src/lib/route-trace-units.ts create mode 100644 src/lib/supabase.ts create mode 100644 src/lib/supabase/server.ts create mode 100644 src/lib/svc-headers.ts create mode 100644 src/lib/water-log-reporting.ts create mode 100644 src/proxy.ts create mode 100644 src/types/index.ts create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/001_checkout_hardening.sql create mode 100644 supabase/migrations/002_remove_broad_anon_select.sql create mode 100644 supabase/migrations/003_audit_logs.sql create mode 100644 supabase/migrations/004_admin_order_fetch.sql create mode 100644 supabase/migrations/005_water_log.sql create mode 100644 supabase/migrations/006_water_log_rpcs_fixed.sql create mode 100644 supabase/migrations/007_water_log_column_fix.sql create mode 100644 supabase/migrations/008_water_log_soft_delete.sql create mode 100644 supabase/migrations/009_water_log_entry_edit_delete.sql create mode 100644 supabase/migrations/010_water_log_unit_types.sql create mode 100644 supabase/migrations/011_water_log_consolidated_fix.sql create mode 100644 supabase/migrations/012_water_log_display_summary.sql create mode 100644 supabase/migrations/013_water_log_seed_admin.sql create mode 100644 supabase/migrations/014_water_sessions_rls.sql create mode 100644 supabase/migrations/015_water_users_security_hardening.sql create mode 100644 supabase/migrations/016_communication_center.sql create mode 100644 supabase/migrations/017_communication_contacts.sql create mode 100644 supabase/migrations/018_contact_import_metadata.sql create mode 100644 supabase/migrations/019_customers_table.sql create mode 100644 supabase/migrations/020_order_customer_linking.sql create mode 100644 supabase/migrations/021_shipping_only_brand.sql create mode 100644 supabase/migrations/022_operational_events.sql create mode 100644 supabase/migrations/023_cart_availability_check.sql create mode 100644 supabase/migrations/024_stop_product_assignment_rpcs.sql create mode 100644 supabase/migrations/025_fix_assignment_rpc_auth.sql create mode 100644 supabase/migrations/026_debug_assignment_rpc.sql create mode 100644 supabase/migrations/027_fix_rpc_signature_conflict.sql create mode 100644 supabase/migrations/028_fix_caller_uid_type.sql create mode 100644 supabase/migrations/029_drop_stale_overloads.sql create mode 100644 supabase/migrations/030_normalize_dev_user_caller_uid.sql create mode 100644 supabase/migrations/031_reports_v1_rpcs.sql create mode 100644 supabase/migrations/032_store_employee_role.sql create mode 100644 supabase/migrations/033_admin_users_crud.sql create mode 100644 supabase/migrations/034_password_change_flow.sql create mode 100644 supabase/migrations/035_admin_action_logs.sql create mode 100644 supabase/migrations/036_user_activity_logs.sql create mode 100644 supabase/migrations/037_update_admin_user_profile.sql create mode 100644 supabase/migrations/038_product_images.sql create mode 100644 supabase/migrations/039_shipping_fulfillment.sql create mode 100644 supabase/migrations/040_shipping_fulfillment_rpcs.sql create mode 100644 supabase/migrations/041_payment_settings.sql create mode 100644 supabase/migrations/042_product_import_rpc.sql create mode 100644 supabase/migrations/043_square_sync_settings.sql create mode 100644 supabase/migrations/044_square_sync_log.sql create mode 100644 supabase/migrations/045_extend_payment_settings_rpcs.sql create mode 100644 supabase/migrations/046_wholesale_portal.sql create mode 100644 supabase/migrations/047_wholesale_registration.sql create mode 100644 supabase/migrations/048_wholesale_enabled.sql create mode 100644 supabase/migrations/049_wholesale_invoice_settings.sql create mode 100644 supabase/migrations/050_wholesale_online_payment.sql create mode 100644 supabase/migrations/051_wholesale_customer_pricing.sql create mode 100644 supabase/migrations/052_wholesale_credit_limit.sql create mode 100644 supabase/migrations/053_wholesale_bulk_actions.sql create mode 100644 supabase/migrations/054_wholesale_email_notifications.sql create mode 100644 supabase/migrations/055_wholesale_notification_settings.sql create mode 100644 supabase/migrations/056_wholesale_price_sheet.sql create mode 100644 supabase/migrations/057_notification_recipients.sql create mode 100644 supabase/migrations/058_fix_get_wholesale_orders.sql create mode 100644 supabase/migrations/059_order_status_delete.sql create mode 100644 supabase/migrations/060_wholesale_pickup_orders.sql create mode 100644 supabase/migrations/061_soft_delete.sql create mode 100644 supabase/migrations/062_wholesale_webhooks.sql create mode 100644 supabase/migrations/063_auto_create_wholesale_settings.sql create mode 100644 supabase/migrations/064_seed_wholesale_settings.sql create mode 100644 supabase/migrations/065_fix_get_wholesale_products.sql create mode 100644 supabase/migrations/066_auto_square_sync.sql create mode 100644 supabase/migrations/067_checkout_session_id.sql create mode 100644 supabase/migrations/068_user_carts.sql create mode 100644 supabase/migrations/069_brand_scoping_phase2.sql create mode 100644 supabase/migrations/070_rls_policy_audit.sql create mode 100644 supabase/migrations/071_wholesale_square_sync_toggle.sql create mode 100644 supabase/migrations/072_orders_brand_scoping.sql create mode 100644 supabase/migrations/073_pickup_order_items_rpc.sql create mode 100644 supabase/migrations/074_products_soft_delete.sql create mode 100644 supabase/migrations/075_stops_soft_delete.sql create mode 100644 supabase/migrations/076_stops_address_cutoff.sql create mode 100644 supabase/migrations/077_stops_status.sql create mode 100644 supabase/migrations/078_wholesale_deposit_guard.sql create mode 100644 supabase/migrations/079_resend_analytics.sql create mode 100644 supabase/migrations/080_communication_segments.sql create mode 100644 supabase/migrations/081_stop_blast_campaign.sql create mode 100644 supabase/migrations/082_brand_name_in_campaign_email.sql create mode 100644 supabase/migrations/083_shipping_settings.sql create mode 100644 supabase/migrations/084_shipments.sql create mode 100644 supabase/migrations/085_brand_settings.sql create mode 100644 supabase/migrations/086_brand_settings_email_integration.sql create mode 100644 supabase/migrations/087_brand_logos_bucket.sql create mode 100644 supabase/migrations/088_brand_features.sql create mode 100644 supabase/migrations/089_ai_providers_custom_integrations.sql create mode 100644 supabase/migrations/090_storefront_settings.sql create mode 100644 supabase/migrations/091_brand_plan_tier.sql create mode 100644 supabase/migrations/092_stripe_subscriptions.sql create mode 100644 supabase/migrations/093_add_hero_image.sql create mode 100644 supabase/migrations/094_brand_color_customization.sql create mode 100644 supabase/migrations/095_tax_settings.sql create mode 100644 supabase/migrations/096_tax_orders_index.sql create mode 100644 supabase/migrations/097_extend_create_order_with_items.sql create mode 100644 supabase/migrations/098_products_is_taxable.sql create mode 100644 supabase/migrations/099_harvest_reach_segmentation.sql create mode 100644 supabase/migrations/108_lot_quantity_tracking.sql create mode 100644 supabase/migrations/109_enable_rls_critical.sql create mode 100644 supabase/migrations/110_rls_zero_policy_tables.sql create mode 100644 supabase/migrations/111_route_trace_recent_activity.sql create mode 100644 supabase/migrations/119_regenerate_headgate_token_brand.sql create mode 100644 supabase/migrations/120_water_alerts.sql create mode 100644 supabase/migrations/126_founder_pain_log.sql create mode 100644 supabase/migrations/127_platform_command_center_rpc.sql create mode 100644 supabase/migrations/128_fix_get_water_headgates_admin_last_used_at.sql create mode 100644 supabase/migrations/129_worker_time_tracking.sql create mode 100644 supabase/migrations/130_time_tracking_pay_period_overtime.sql create mode 100644 supabase/migrations/133_time_tracking_notifications.sql create mode 100644 supabase/migrations/134_abandoned_cart_recovery.sql create mode 100644 supabase/migrations/135_email_automation_rpcs.sql create mode 100644 supabase/migrations/136_email_automation_missing_rpcs.sql create mode 100644 supabase/migrations/137_time_tracking_workers_and_tasks.sql create mode 100644 supabase/migrations/138_time_tracking_logs.sql create mode 100644 supabase/migrations/139_time_tracking_missing_rpcs.sql create mode 100644 supabase/migrations/140_time_tracking_field_rpcs.sql create mode 100644 supabase/migrations/141_update_user_password_rpc.sql create mode 100644 supabase/migrations/142_integration_credentials.sql create mode 100644 supabase/migrations/BUNDLE_018_042.sql create mode 100644 supabase/push-migrations.js create mode 100644 test-cookie.cjs create mode 100644 test-results/.last-run.json create mode 100644 tests/login/login-flow.spec.ts create mode 100644 tests/smoke.spec.ts create mode 100644 tsconfig.json create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81d5216 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ +.pnp +.pnp.* + +# Build outputs +.next/ +out/ +build/ +dist/ + +# Package files +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Misc +.DS_Store +*.pem + +# Supabase +supabase/.temp/ + +# IDE / local config +.mcp.json +.env* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2479a62 --- /dev/null +++ b/.gitmodules @@ -0,0 +1 @@ +# Empty gitmodules - no submodules used diff --git a/.playwright-mcp/page-2026-05-25T23-19-03-686Z.yml b/.playwright-mcp/page-2026-05-25T23-19-03-686Z.yml new file mode 100644 index 0000000..aeedcec --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-19-03-686Z.yml @@ -0,0 +1,55 @@ +- generic [active] [ref=e1]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - generic [ref=e3]: + - banner [ref=e4]: + - link "Vercel logo": + - /url: /home + - button "Vercel Logo": + - img "Vercel Logo" + - navigation [ref=e5]: + - navigation [ref=e6]: + - link "Sign Up" [ref=e7] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-f88334u7l-kylemart88480s-projects.vercel.app%252Fadmin%252Fsettings%252Fpayments%26nonce%3Db85db59f9806d8f02f9dcb195781ff0d984596c63b3d82a1d8896cfcd931599c + - paragraph [ref=e9]: Sign Up + - main [ref=e10]: + - generic [ref=e12]: + - heading "Log in to Vercel" [level=1] [ref=e15] + - generic [ref=e16]: + - generic [ref=e17]: + - textbox "Email Address" [ref=e19] + - button "Continue with Email" [ref=e21] [cursor=pointer]: + - generic [ref=e22]: Continue with Email + - generic [ref=e24]: + - button "Continue with Google" [ref=e25] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e34]: Continue with Google + - button "Continue with GitHub" [ref=e35] [cursor=pointer]: + - img [ref=e37] + - generic [ref=e41]: Continue with GitHub + - button "Continue with Apple" [ref=e42] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e47]: Continue with Apple + - button "Continue with SAML SSO" [ref=e49] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e53]: Continue with SAML SSO + - button "Continue with Passkey" [ref=e54] [cursor=pointer]: + - img [ref=e56] + - generic [ref=e58]: Continue with Passkey + - button "Show other options" [ref=e59] [cursor=pointer]: + - generic [ref=e60]: Show other options + - paragraph [ref=e62]: + - text: Don't have an account? + - link "Sign Up" [ref=e63] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-f88334u7l-kylemart88480s-projects.vercel.app%252Fadmin%252Fsettings%252Fpayments%26nonce%3Db85db59f9806d8f02f9dcb195781ff0d984596c63b3d82a1d8896cfcd931599c + - generic [ref=e66]: + - link "Terms" [ref=e67] [cursor=pointer]: + - /url: /legal/terms + - link "Privacy Policy" [ref=e68] [cursor=pointer]: + - /url: /legal/privacy-policy + - alert [ref=e69] + - generic: + - generic: + - generic: + - generic: + - img \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-19-36-520Z.yml b/.playwright-mcp/page-2026-05-25T23-19-36-520Z.yml new file mode 100644 index 0000000..f9db213 --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-19-36-520Z.yml @@ -0,0 +1,55 @@ +- generic [active] [ref=e1]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - generic [ref=e3]: + - banner [ref=e4]: + - link "Vercel logo": + - /url: /home + - button "Vercel Logo": + - img "Vercel Logo" + - navigation [ref=e5]: + - navigation [ref=e6]: + - link "Sign Up" [ref=e7] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-f88334u7l-kylemart88480s-projects.vercel.app%252Flogin%26nonce%3Db85db59f9806d8f02f9dcb195781ff0d984596c63b3d82a1d8896cfcd931599c + - paragraph [ref=e9]: Sign Up + - main [ref=e10]: + - generic [ref=e12]: + - heading "Log in to Vercel" [level=1] [ref=e15] + - generic [ref=e16]: + - generic [ref=e17]: + - textbox "Email Address" [ref=e19] + - button "Continue with Email" [ref=e21] [cursor=pointer]: + - generic [ref=e22]: Continue with Email + - generic [ref=e24]: + - button "Continue with Google" [ref=e25] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e34]: Continue with Google + - button "Continue with GitHub" [ref=e35] [cursor=pointer]: + - img [ref=e37] + - generic [ref=e41]: Continue with GitHub + - button "Continue with Apple" [ref=e42] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e47]: Continue with Apple + - button "Continue with SAML SSO" [ref=e49] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e53]: Continue with SAML SSO + - button "Continue with Passkey" [ref=e54] [cursor=pointer]: + - img [ref=e56] + - generic [ref=e58]: Continue with Passkey + - button "Show other options" [ref=e59] [cursor=pointer]: + - generic [ref=e60]: Show other options + - paragraph [ref=e62]: + - text: Don't have an account? + - link "Sign Up" [ref=e63] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-f88334u7l-kylemart88480s-projects.vercel.app%252Flogin%26nonce%3Db85db59f9806d8f02f9dcb195781ff0d984596c63b3d82a1d8896cfcd931599c + - generic [ref=e66]: + - link "Terms" [ref=e67] [cursor=pointer]: + - /url: /legal/terms + - link "Privacy Policy" [ref=e68] [cursor=pointer]: + - /url: /legal/privacy-policy + - alert [ref=e69] + - generic: + - generic: + - generic: + - generic: + - img \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-25-08-906Z.yml b/.playwright-mcp/page-2026-05-25T23-25-08-906Z.yml new file mode 100644 index 0000000..1d20790 --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-25-08-906Z.yml @@ -0,0 +1,4 @@ +- generic [active]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - alert [ref=e1] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-25-17-649Z.yml b/.playwright-mcp/page-2026-05-25T23-25-17-649Z.yml new file mode 100644 index 0000000..5b73f3f --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-25-17-649Z.yml @@ -0,0 +1,54 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e23]: Dev access + - generic [ref=e24]: + - link "Platform Admin" [ref=e25] [cursor=pointer]: + - /url: /api/force-admin?role=platform_admin + - link "Brand Admin" [ref=e26] [cursor=pointer]: + - /url: /api/force-admin?role=brand_admin + - link "Store Employee" [ref=e27] [cursor=pointer]: + - /url: /api/force-admin?role=store_employee + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: Email + - textbox "Email" [ref=e31]: + - /placeholder: admin@example.com + - generic [ref=e32]: + - generic [ref=e33]: Password + - textbox "Password" [ref=e34]: + - /placeholder: •••••••• + - button "Sign In" [ref=e35] + - button "Forgot password?" [ref=e36] + - link "← Back to storefront" [ref=e37] [cursor=pointer]: + - /url: / + - contentinfo [ref=e38]: + - generic [ref=e40]: + - paragraph [ref=e41]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e42]: + - link "Privacy Policy" [ref=e43] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e44] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e45] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e46] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-25-23-634Z.yml b/.playwright-mcp/page-2026-05-25T23-25-23-634Z.yml new file mode 100644 index 0000000..cb3ff21 --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-25-23-634Z.yml @@ -0,0 +1,49 @@ +- generic [active] [ref=e1]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - generic [ref=e3]: + - banner [ref=e4]: + - link "Vercel logo": + - /url: /home + - button "Vercel Logo": + - img "Vercel Logo" + - navigation [ref=e5]: + - navigation [ref=e6]: + - link "Sign Up" [ref=e7] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-f88334u7l-kylemart88480s-projects.vercel.app%252Fadmin%252Fsettings%252Fpayments%26nonce%3Db85db59f9806d8f02f9dcb195781ff0d984596c63b3d82a1d8896cfcd931599c + - paragraph [ref=e9]: Sign Up + - main [ref=e10]: + - generic [ref=e12]: + - heading "Log in to Vercel" [level=1] [ref=e15] + - generic [ref=e16]: + - generic [ref=e17]: + - textbox "Email Address" [ref=e19] + - button "Continue with Email" [ref=e21] [cursor=pointer]: + - generic [ref=e22]: Continue with Email + - generic [ref=e24]: + - button "Continue with Google" [ref=e25] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e34]: Continue with Google + - button "Continue with GitHub" [ref=e35] [cursor=pointer]: + - img [ref=e37] + - generic [ref=e41]: Continue with GitHub + - button "Continue with Apple" [ref=e42] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e47]: Continue with Apple + - button "Continue with SAML SSO" [ref=e49] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e53]: Continue with SAML SSO + - button "Continue with Passkey" [ref=e54] [cursor=pointer]: + - img [ref=e56] + - generic [ref=e58]: Continue with Passkey + - button "Show other options" [ref=e59] [cursor=pointer]: + - generic [ref=e60]: Show other options + - paragraph [ref=e62]: + - text: Don't have an account? + - link "Sign Up" [ref=e63] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-f88334u7l-kylemart88480s-projects.vercel.app%252Fadmin%252Fsettings%252Fpayments%26nonce%3Db85db59f9806d8f02f9dcb195781ff0d984596c63b3d82a1d8896cfcd931599c + - generic [ref=e66]: + - link "Terms" [ref=e67] [cursor=pointer]: + - /url: /legal/terms + - link "Privacy Policy" [ref=e68] [cursor=pointer]: + - /url: /legal/privacy-policy \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-25-53-663Z.yml b/.playwright-mcp/page-2026-05-25T23-25-53-663Z.yml new file mode 100644 index 0000000..fe2e80b --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-25-53-663Z.yml @@ -0,0 +1,3 @@ +- generic [active]: + - link "Skip to content": + - /url: "#geist-skip-nav" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-26-18-545Z.yml b/.playwright-mcp/page-2026-05-25T23-26-18-545Z.yml new file mode 100644 index 0000000..8fd3777 --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-26-18-545Z.yml @@ -0,0 +1,53 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - link "Cart (0)" [ref=e10] [cursor=pointer]: + - /url: /cart + - main [ref=e11]: + - generic [ref=e12]: + - generic [ref=e13]: + - heading "Admin Login" [level=1] [ref=e14] + - paragraph [ref=e15]: Sign in with your account. + - generic [ref=e16]: + - generic [ref=e21]: Dev access + - generic [ref=e22]: + - link "Platform Admin" [ref=e23] [cursor=pointer]: + - /url: /api/force-admin?role=platform_admin + - link "Brand Admin" [ref=e24] [cursor=pointer]: + - /url: /api/force-admin?role=brand_admin + - link "Store Employee" [ref=e25] [cursor=pointer]: + - /url: /api/force-admin?role=store_employee + - generic [ref=e26]: + - generic [ref=e27]: + - generic [ref=e28]: Email + - textbox "Email" [ref=e29]: + - /placeholder: admin@example.com + - generic [ref=e30]: + - generic [ref=e31]: Password + - textbox "Password" [ref=e32]: + - /placeholder: •••••••• + - button "Sign In" [ref=e33] + - button "Forgot password?" [ref=e34] + - link "← Back to storefront" [ref=e35] [cursor=pointer]: + - /url: / + - contentinfo [ref=e36]: + - generic [ref=e38]: + - paragraph [ref=e39]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e40]: + - link "Privacy Policy" [ref=e41] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e42] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e43] [cursor=pointer]: + - /url: https://cielohermosa.com + - button "Open Next.js Dev Tools" [ref=e49] [cursor=pointer]: + - img [ref=e50] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-26-24-537Z.yml b/.playwright-mcp/page-2026-05-25T23-26-24-537Z.yml new file mode 100644 index 0000000..8fd3777 --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-26-24-537Z.yml @@ -0,0 +1,53 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - link "Cart (0)" [ref=e10] [cursor=pointer]: + - /url: /cart + - main [ref=e11]: + - generic [ref=e12]: + - generic [ref=e13]: + - heading "Admin Login" [level=1] [ref=e14] + - paragraph [ref=e15]: Sign in with your account. + - generic [ref=e16]: + - generic [ref=e21]: Dev access + - generic [ref=e22]: + - link "Platform Admin" [ref=e23] [cursor=pointer]: + - /url: /api/force-admin?role=platform_admin + - link "Brand Admin" [ref=e24] [cursor=pointer]: + - /url: /api/force-admin?role=brand_admin + - link "Store Employee" [ref=e25] [cursor=pointer]: + - /url: /api/force-admin?role=store_employee + - generic [ref=e26]: + - generic [ref=e27]: + - generic [ref=e28]: Email + - textbox "Email" [ref=e29]: + - /placeholder: admin@example.com + - generic [ref=e30]: + - generic [ref=e31]: Password + - textbox "Password" [ref=e32]: + - /placeholder: •••••••• + - button "Sign In" [ref=e33] + - button "Forgot password?" [ref=e34] + - link "← Back to storefront" [ref=e35] [cursor=pointer]: + - /url: / + - contentinfo [ref=e36]: + - generic [ref=e38]: + - paragraph [ref=e39]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e40]: + - link "Privacy Policy" [ref=e41] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e42] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e43] [cursor=pointer]: + - /url: https://cielohermosa.com + - button "Open Next.js Dev Tools" [ref=e49] [cursor=pointer]: + - img [ref=e50] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-46-05-582Z.yml b/.playwright-mcp/page-2026-05-25T23-46-05-582Z.yml new file mode 100644 index 0000000..407a076 --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-46-05-582Z.yml @@ -0,0 +1,54 @@ +- generic [ref=e1]: + - generic [active]: + - generic [ref=e4]: + - generic [ref=e5]: + - generic [ref=e6]: + - navigation [ref=e7]: + - button "previous" [disabled] [ref=e8]: + - img "previous" [ref=e9] + - generic [ref=e11]: + - generic [ref=e12]: 1/ + - text: "1" + - button "next" [disabled] [ref=e13]: + - img "next" [ref=e14] + - img + - generic [ref=e16]: + - link "Next.js 16.2.5 (stale) Webpack" [ref=e17] [cursor=pointer]: + - /url: https://nextjs.org/docs/messages/version-staleness + - img [ref=e18] + - generic "There is a newer version (16.2.6) available, upgrade recommended!" [ref=e20]: Next.js 16.2.5 (stale) + - generic [ref=e21]: Webpack + - img + - dialog "Runtime Error" [ref=e23]: + - generic [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: Runtime Error + - generic [ref=e31]: Server + - generic [ref=e32]: + - button "Copy Error Info" [ref=e33] [cursor=pointer]: + - img [ref=e34] + - button "No related documentation found" [disabled] [ref=e36]: + - img [ref=e37] + - button "Attach Node.js inspector" [ref=e39] [cursor=pointer]: + - img [ref=e40] + - generic [ref=e49]: "Missing Supabase env vars: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY. Check Vercel environment variables for Production environment. Node env: development" + - generic [ref=e50]: "1" + - generic [ref=e51]: "2" + - generic [ref=e56] [cursor=pointer]: + - button "Open Next.js Dev Tools" [ref=e57]: + - img [ref=e58] + - generic [ref=e61]: + - button "Open issues overlay" [ref=e62]: + - generic [ref=e63]: + - generic [ref=e64]: "0" + - generic [ref=e65]: "1" + - generic [ref=e66]: Issue + - button "Collapse issues badge" [ref=e67]: + - img [ref=e68] + - generic [ref=e71]: + - img [ref=e72] + - heading "This page couldn’t load" [level=1] [ref=e74] + - paragraph [ref=e75]: A server error occurred. Reload to try again. + - button "Reload" [ref=e78] [cursor=pointer] + - paragraph [ref=e79]: ERROR 183137976 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-46-35-195Z.yml b/.playwright-mcp/page-2026-05-25T23-46-35-195Z.yml new file mode 100644 index 0000000..f9db213 --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-46-35-195Z.yml @@ -0,0 +1,55 @@ +- generic [active] [ref=e1]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - generic [ref=e3]: + - banner [ref=e4]: + - link "Vercel logo": + - /url: /home + - button "Vercel Logo": + - img "Vercel Logo" + - navigation [ref=e5]: + - navigation [ref=e6]: + - link "Sign Up" [ref=e7] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-f88334u7l-kylemart88480s-projects.vercel.app%252Flogin%26nonce%3Db85db59f9806d8f02f9dcb195781ff0d984596c63b3d82a1d8896cfcd931599c + - paragraph [ref=e9]: Sign Up + - main [ref=e10]: + - generic [ref=e12]: + - heading "Log in to Vercel" [level=1] [ref=e15] + - generic [ref=e16]: + - generic [ref=e17]: + - textbox "Email Address" [ref=e19] + - button "Continue with Email" [ref=e21] [cursor=pointer]: + - generic [ref=e22]: Continue with Email + - generic [ref=e24]: + - button "Continue with Google" [ref=e25] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e34]: Continue with Google + - button "Continue with GitHub" [ref=e35] [cursor=pointer]: + - img [ref=e37] + - generic [ref=e41]: Continue with GitHub + - button "Continue with Apple" [ref=e42] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e47]: Continue with Apple + - button "Continue with SAML SSO" [ref=e49] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e53]: Continue with SAML SSO + - button "Continue with Passkey" [ref=e54] [cursor=pointer]: + - img [ref=e56] + - generic [ref=e58]: Continue with Passkey + - button "Show other options" [ref=e59] [cursor=pointer]: + - generic [ref=e60]: Show other options + - paragraph [ref=e62]: + - text: Don't have an account? + - link "Sign Up" [ref=e63] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-f88334u7l-kylemart88480s-projects.vercel.app%252Flogin%26nonce%3Db85db59f9806d8f02f9dcb195781ff0d984596c63b3d82a1d8896cfcd931599c + - generic [ref=e66]: + - link "Terms" [ref=e67] [cursor=pointer]: + - /url: /legal/terms + - link "Privacy Policy" [ref=e68] [cursor=pointer]: + - /url: /legal/privacy-policy + - alert [ref=e69] + - generic: + - generic: + - generic: + - generic: + - img \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-25T23-46-57-871Z.png b/.playwright-mcp/page-2026-05-25T23-46-57-871Z.png new file mode 100644 index 0000000000000000000000000000000000000000..bf44e3f5a46c13e179cadaaa2ad8888ee54e9149 GIT binary patch literal 42658 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 XYsuE*cRcVXRHx*WWiyVSzwzGyh=q~0 literal 0 HcmV?d00001 diff --git a/.playwright-mcp/page-2026-05-25T23-49-24-803Z.yml b/.playwright-mcp/page-2026-05-25T23-49-24-803Z.yml new file mode 100644 index 0000000..1d20790 --- /dev/null +++ b/.playwright-mcp/page-2026-05-25T23-49-24-803Z.yml @@ -0,0 +1,4 @@ +- generic [active]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - alert [ref=e1] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T02-09-06-979Z.yml b/.playwright-mcp/page-2026-05-26T02-09-06-979Z.yml new file mode 100644 index 0000000..e77ebe7 --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T02-09-06-979Z.yml @@ -0,0 +1,51 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - link "Cart (0)" [ref=e10] [cursor=pointer]: + - /url: /cart + - main [ref=e11]: + - generic [ref=e12]: + - generic [ref=e13]: + - heading "Admin Login" [level=1] [ref=e14] + - paragraph [ref=e15]: Sign in with your account. + - generic [ref=e16]: + - generic [ref=e21]: Dev access + - generic [ref=e22]: + - link "Platform Admin" [ref=e23] [cursor=pointer]: + - /url: /api/force-admin?role=platform_admin + - link "Brand Admin" [ref=e24] [cursor=pointer]: + - /url: /api/force-admin?role=brand_admin + - link "Store Employee" [ref=e25] [cursor=pointer]: + - /url: /api/force-admin?role=store_employee + - generic [ref=e26]: + - generic [ref=e27]: + - generic [ref=e28]: Email + - textbox "Email" [ref=e29]: + - /placeholder: admin@example.com + - generic [ref=e30]: + - generic [ref=e31]: Password + - textbox "Password" [ref=e32]: + - /placeholder: •••••••• + - button "Sign In" [ref=e33] + - button "Forgot password?" [ref=e34] + - link "← Back to storefront" [ref=e35] [cursor=pointer]: + - /url: / + - contentinfo [ref=e36]: + - generic [ref=e38]: + - paragraph [ref=e39]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e40]: + - link "Privacy Policy" [ref=e41] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e42] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e43] [cursor=pointer]: + - /url: https://cielohermosa.com \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-09-04-342Z.yml b/.playwright-mcp/page-2026-05-26T05-09-04-342Z.yml new file mode 100644 index 0000000..bfa01ec --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-09-04-342Z.yml @@ -0,0 +1,42 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - heading "Route Commerce Platform" [level=1] [ref=e15] + - paragraph [ref=e16]: A shared commerce and operations platform for Tuxedo Corn and Indian River Direct. + - generic [ref=e17]: + - link "Tuxedo Corn Tour stops, sweet corn orders, shipping, pickup, and onsite sales." [ref=e18] [cursor=pointer]: + - /url: /tuxedo + - heading "Tuxedo Corn" [level=2] [ref=e19] + - paragraph [ref=e20]: Tour stops, sweet corn orders, shipping, pickup, and onsite sales. + - link "Indian River Direct Citrus tours, ZIP code stop finder, orders, and customer communications." [ref=e21] [cursor=pointer]: + - /url: /indian-river-direct + - heading "Indian River Direct" [level=2] [ref=e22] + - paragraph [ref=e23]: Citrus tours, ZIP code stop finder, orders, and customer communications. + - link "Open Admin Dashboard" [ref=e24] [cursor=pointer]: + - /url: /admin + - contentinfo [ref=e25]: + - generic [ref=e27]: + - paragraph [ref=e28]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e29]: + - link "Privacy Policy" [ref=e30] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e31] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e32] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e33] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-09-16-533Z.yml b/.playwright-mcp/page-2026-05-26T05-09-16-533Z.yml new file mode 100644 index 0000000..bb6468d --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-09-16-533Z.yml @@ -0,0 +1,45 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [ref=e21]: + - /placeholder: admin@example.com + - generic [ref=e22]: + - generic [ref=e23]: Password + - textbox "Password" [ref=e24]: + - /placeholder: •••••••• + - button "Sign In" [ref=e25] + - button "Forgot password?" [ref=e26] + - link "← Back to storefront" [ref=e27] [cursor=pointer]: + - /url: / + - contentinfo [ref=e28]: + - generic [ref=e30]: + - paragraph [ref=e31]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e32]: + - link "Privacy Policy" [ref=e33] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e34] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e35] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-09-26-150Z.yml b/.playwright-mcp/page-2026-05-26T05-09-26-150Z.yml new file mode 100644 index 0000000..bb6468d --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-09-26-150Z.yml @@ -0,0 +1,45 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [ref=e21]: + - /placeholder: admin@example.com + - generic [ref=e22]: + - generic [ref=e23]: Password + - textbox "Password" [ref=e24]: + - /placeholder: •••••••• + - button "Sign In" [ref=e25] + - button "Forgot password?" [ref=e26] + - link "← Back to storefront" [ref=e27] [cursor=pointer]: + - /url: / + - contentinfo [ref=e28]: + - generic [ref=e30]: + - paragraph [ref=e31]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e32]: + - link "Privacy Policy" [ref=e33] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e34] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e35] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-09-36-552Z.yml b/.playwright-mcp/page-2026-05-26T05-09-36-552Z.yml new file mode 100644 index 0000000..69e2d29 --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-09-36-552Z.yml @@ -0,0 +1,168 @@ +- generic [active] [ref=e1]: + - alert [ref=e2] + - generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: + - link "Tuxedo Corn" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - img "Tuxedo Corn" [ref=e7] + - navigation [ref=e8]: + - link "Home" [ref=e9] [cursor=pointer]: + - /url: /tuxedo + - link "Stops" [ref=e10] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e11] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e12] [cursor=pointer]: + - /url: /tuxedo/about + - link "FAQ" [ref=e13] [cursor=pointer]: + - /url: /tuxedo/faq + - link "Contact" [ref=e14] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Wholesale" [ref=e15] [cursor=pointer]: + - /url: /wholesale/login + - link "Cart" [ref=e17] [cursor=pointer]: + - /url: /cart + - main [ref=e18]: + - generic [ref=e22]: + - img "Olathe Sweet" [ref=e24] + - paragraph [ref=e25]: Olathe Sweet™ — Olathe, Colorado + - heading "Tuxedo Corn" [level=1] [ref=e26] + - paragraph [ref=e27]: Premium Sweet Corn, Farm-Direct to You + - generic [ref=e28]: + - button "Find a Stop" [ref=e29] + - button "Our Story" [ref=e30]: + - generic [ref=e31]: Our Story + - img [ref=e32] + - generic [ref=e36]: + - generic [ref=e37]: + - img [ref=e39] + - generic [ref=e41]: Hand-Harvested + - generic [ref=e43]: + - img [ref=e45] + - generic [ref=e47]: Non-GMO Verified + - generic [ref=e49]: + - img [ref=e51] + - generic [ref=e54]: Colorado Grown + - generic [ref=e56]: + - img [ref=e58] + - generic [ref=e60]: Regeneratively Grown + - generic [ref=e63]: + - generic [ref=e64]: + - img [ref=e66] + - generic [ref=e68]: + - paragraph [ref=e69]: Peak Freshness + - paragraph [ref=e70]: Harvested at peak ripeness for maximum sweetness and flavor — every order is hand-selected from the field. + - generic [ref=e71]: + - img [ref=e73] + - generic [ref=e76]: + - paragraph [ref=e77]: Grown in Colorado + - paragraph [ref=e78]: Family farm in the Centennial State — Olathe Sweet™ is a local original. + - generic [ref=e79]: + - img [ref=e81] + - generic [ref=e83]: + - paragraph [ref=e84]: No Middleman + - paragraph [ref=e85]: Order direct from the farm. Fresher corn, better prices for everyone. + - generic [ref=e86]: + - img [ref=e88] + - generic [ref=e90]: + - paragraph [ref=e91]: Easy Preorder + - paragraph [ref=e92]: Reserve ahead of time. Your order is waiting for you at the stop. + - generic [ref=e94]: + - generic [ref=e96]: + - paragraph [ref=e97]: Delivery Stops + - heading "Upcoming Stops" [level=2] [ref=e98]: + - text: Upcoming + - text: Stops + - paragraph [ref=e100]: Find a nearby stop and preorder your corn. Pickup is easy and guaranteed. + - paragraph [ref=e102]: No upcoming stops scheduled. Check back soon! + - generic [ref=e104]: + - generic [ref=e105]: + - paragraph [ref=e106]: Farm-Direct + - heading "This Week's Harvest" [level=2] [ref=e107]: + - text: This Week's + - text: Harvest + - paragraph [ref=e109]: Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season. + - paragraph [ref=e111]: No products available at this time. + - generic [ref=e114]: + - paragraph [ref=e115]: Our Story + - heading "Three Generations of Sweet Corn Excellence" [level=2] [ref=e116] + - paragraph [ref=e118]: Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn — developed for Colorado's high-altitude mountain climate and grown by the same family for over 40 years. + - generic [ref=e119]: + - generic [ref=e120]: + - paragraph [ref=e121]: 40+ + - paragraph [ref=e122]: Years + - generic [ref=e123]: + - paragraph [ref=e124]: "3" + - paragraph [ref=e125]: Generations + - generic [ref=e126]: + - paragraph [ref=e127]: 100% + - paragraph [ref=e128]: Hand-Picked + - link "Read Our Story" [ref=e129] [cursor=pointer]: + - /url: /tuxedo/about + - text: Read Our Story + - img [ref=e130] + - generic [ref=e134]: + - generic [ref=e135]: + - paragraph [ref=e136]: Stay in the loop + - heading "Harvest updates, new stops & seasonal news" [level=3] [ref=e137] + - generic [ref=e138]: + - textbox "your@email.com" [ref=e139] + - button "Subscribe" [ref=e140] + - contentinfo [ref=e141]: + - generic [ref=e142]: + - generic [ref=e143]: + - generic [ref=e144]: + - img "Tuxedo Corn" [ref=e145] + - paragraph [ref=e146]: Fresh from the farm to your family. We grow and deliver premium produce with care you can taste and trust. + - generic [ref=e147]: + - link "Instagram" [ref=e148] [cursor=pointer]: + - /url: https://instagram.com + - img [ref=e149] + - link "Facebook" [ref=e151] [cursor=pointer]: + - /url: https://facebook.com + - img [ref=e152] + - link "970-323-6874" [ref=e154] [cursor=pointer]: + - /url: tel:9703236874 + - link "info@tuxedofarmco.com" [ref=e155] [cursor=pointer]: + - /url: mailto:info@tuxedofarmco.com + - generic [ref=e156]: + - paragraph [ref=e157]: Shop + - navigation [ref=e158]: + - link "Home" [ref=e159] [cursor=pointer]: + - /url: /tuxedo + - link "Upcoming Stops" [ref=e160] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e161] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e162] [cursor=pointer]: + - /url: /tuxedo/about + - generic [ref=e163]: + - paragraph [ref=e164]: Help + - navigation [ref=e165]: + - link "Cart" [ref=e166] [cursor=pointer]: + - /url: /cart + - link "Wholesale Portal" [ref=e167] [cursor=pointer]: + - /url: /wholesale/login + - link "Contact Us" [ref=e168] [cursor=pointer]: + - /url: /tuxedo/contact + - link "FAQ" [ref=e169] [cursor=pointer]: + - /url: /tuxedo/faq + - generic [ref=e170]: + - paragraph [ref=e171]: Fresh & Local + - navigation [ref=e172]: + - link "Our Farm" [ref=e173] [cursor=pointer]: + - /url: /tuxedo/about + - link "What's in Season" [ref=e174] [cursor=pointer]: + - /url: /tuxedo#products + - link "Visit Us" [ref=e175] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Sell With Us" [ref=e176] [cursor=pointer]: + - /url: /wholesale/register + - generic [ref=e177]: + - paragraph [ref=e178]: © 2026 Tuxedo Corn. All rights reserved. + - generic [ref=e179]: + - link "Privacy Policy" [ref=e180] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e181] [cursor=pointer]: + - /url: /terms-and-conditions \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-09-46-053Z.yml b/.playwright-mcp/page-2026-05-26T05-09-46-053Z.yml new file mode 100644 index 0000000..bb6468d --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-09-46-053Z.yml @@ -0,0 +1,45 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [ref=e21]: + - /placeholder: admin@example.com + - generic [ref=e22]: + - generic [ref=e23]: Password + - textbox "Password" [ref=e24]: + - /placeholder: •••••••• + - button "Sign In" [ref=e25] + - button "Forgot password?" [ref=e26] + - link "← Back to storefront" [ref=e27] [cursor=pointer]: + - /url: / + - contentinfo [ref=e28]: + - generic [ref=e30]: + - paragraph [ref=e31]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e32]: + - link "Privacy Policy" [ref=e33] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e34] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e35] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-09-54-824Z.yml b/.playwright-mcp/page-2026-05-26T05-09-54-824Z.yml new file mode 100644 index 0000000..c466587 --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-09-54-824Z.yml @@ -0,0 +1,30 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - generic [ref=e14]: + - heading "404" [level=1] [ref=e15] + - heading "This page could not be found." [level=2] [ref=e17] + - contentinfo [ref=e18]: + - generic [ref=e20]: + - paragraph [ref=e21]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e22]: + - link "Privacy Policy" [ref=e23] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e24] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e25] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e26] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-10-32-126Z.yml b/.playwright-mcp/page-2026-05-26T05-10-32-126Z.yml new file mode 100644 index 0000000..7827f03 --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-10-32-126Z.yml @@ -0,0 +1,9 @@ +- generic [active] [ref=e1]: + - alert [ref=e2] + - generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e5]: + - img "Olathe Sweet" [ref=e6] + - generic [ref=e7]: Tuxedo Corn + - button "ES" [ref=e8] + - generic [ref=e11]: Loading... \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-11-15-363Z.yml b/.playwright-mcp/page-2026-05-26T05-11-15-363Z.yml new file mode 100644 index 0000000..090e95c --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-11-15-363Z.yml @@ -0,0 +1,95 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - generic [ref=e13]: + - generic [ref=e15]: + - generic [ref=e16]: + - img [ref=e18] + - generic [ref=e20]: + - paragraph [ref=e21]: Tuxedo Corn + - paragraph [ref=e22]: Wholesale Portal + - link "← Back to site" [ref=e23] [cursor=pointer]: + - /url: / + - generic [ref=e25]: + - generic [ref=e26]: + - heading "Grow your business with wholesale pricing" [level=2] [ref=e27] + - paragraph [ref=e28]: Join our wholesale program and get access to exclusive pricing, easier ordering, and priority fulfillment. + - generic [ref=e29]: + - paragraph [ref=e30]: What You'll Get + - list [ref=e31]: + - listitem [ref=e32]: + - img [ref=e34] + - generic [ref=e36]: + - paragraph [ref=e37]: Exclusive Wholesale Pricing + - paragraph [ref=e38]: Access discounted rates and volume-based tiers that aren't available to retail customers. + - listitem [ref=e39]: + - img [ref=e41] + - generic [ref=e43]: + - paragraph [ref=e44]: Easy Online Ordering + - paragraph [ref=e45]: Place orders anytime with the option to pay a deposit and settle the balance at pickup. + - listitem [ref=e46]: + - img [ref=e48] + - generic [ref=e50]: + - paragraph [ref=e51]: Order History & Invoices + - paragraph [ref=e52]: Log in anytime to review past orders, download invoices, and track your spending. + - listitem [ref=e53]: + - img [ref=e55] + - generic [ref=e57]: + - paragraph [ref=e58]: Priority Pickup Scheduling + - paragraph [ref=e59]: Reserve your spot at upcoming stops and ensure your order is ready when you arrive. + - listitem [ref=e60]: + - img [ref=e62] + - generic [ref=e64]: + - paragraph [ref=e65]: Dedicated Bulk Support + - paragraph [ref=e66]: Get personalized help from our wholesale team for large or recurring orders. + - listitem [ref=e67]: + - img [ref=e69] + - generic [ref=e71]: + - paragraph [ref=e72]: Early Access to Seasonal Products + - paragraph [ref=e73]: Be the first to know when new crops launch and reserve before they sell out. + - generic [ref=e74]: + - generic [ref=e75]: + - heading "Sign In" [level=1] [ref=e76] + - paragraph [ref=e77]: Access your wholesale account. + - generic [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: Company + - combobox [ref=e81]: + - option "Indian River Direct" + - option "Tuxedo Corn" [selected] + - generic [ref=e82]: + - generic [ref=e83]: Email + - textbox "buyer@company.com" [ref=e84] + - generic [ref=e85]: + - generic [ref=e86]: Password + - textbox "••••••••" [ref=e87] + - button "Sign In" [ref=e88] + - generic [ref=e89]: + - paragraph [ref=e90]: Don't have an account? + - link "Apply for a wholesale account →" [ref=e91] [cursor=pointer]: + - /url: /wholesale/register + - paragraph [ref=e93]: Powered by Route Commerce + - contentinfo [ref=e94]: + - generic [ref=e96]: + - paragraph [ref=e97]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e98]: + - link "Privacy Policy" [ref=e99] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e100] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e101] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e102] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-11-26-680Z.yml b/.playwright-mcp/page-2026-05-26T05-11-26-680Z.yml new file mode 100644 index 0000000..452a4b8 --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-11-26-680Z.yml @@ -0,0 +1,28 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - paragraph [ref=e14]: Loading... + - contentinfo [ref=e15]: + - generic [ref=e17]: + - paragraph [ref=e18]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e19]: + - link "Privacy Policy" [ref=e20] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e21] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e22] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e23] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-11-36-883Z.yml b/.playwright-mcp/page-2026-05-26T05-11-36-883Z.yml new file mode 100644 index 0000000..bb6468d --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-11-36-883Z.yml @@ -0,0 +1,45 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [ref=e21]: + - /placeholder: admin@example.com + - generic [ref=e22]: + - generic [ref=e23]: Password + - textbox "Password" [ref=e24]: + - /placeholder: •••••••• + - button "Sign In" [ref=e25] + - button "Forgot password?" [ref=e26] + - link "← Back to storefront" [ref=e27] [cursor=pointer]: + - /url: / + - contentinfo [ref=e28]: + - generic [ref=e30]: + - paragraph [ref=e31]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e32]: + - link "Privacy Policy" [ref=e33] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e34] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e35] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-11-47-458Z.yml b/.playwright-mcp/page-2026-05-26T05-11-47-458Z.yml new file mode 100644 index 0000000..a37e020 --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-11-47-458Z.yml @@ -0,0 +1,132 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - generic [ref=e13]: + - banner [ref=e14]: + - generic [ref=e15]: + - link "Your Cart" [ref=e16] [cursor=pointer]: + - /url: /tuxedo + - generic [ref=e17]: Your Cart + - navigation [ref=e18]: + - link "Home" [ref=e19] [cursor=pointer]: + - /url: /tuxedo + - link "Stops" [ref=e20] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e21] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e22] [cursor=pointer]: + - /url: /tuxedo/about + - link "FAQ" [ref=e23] [cursor=pointer]: + - /url: /tuxedo/faq + - link "Contact" [ref=e24] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Wholesale" [ref=e25] [cursor=pointer]: + - /url: /wholesale/login + - link "Cart" [ref=e27] [cursor=pointer]: + - /url: /cart + - main [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: + - heading "Shopping Cart" [level=1] [ref=e31] + - paragraph [ref=e32]: Review your products before checkout. + - generic [ref=e34]: + - paragraph [ref=e35]: Your cart is empty. + - link "← Continue shopping" [ref=e36] [cursor=pointer]: + - /url: / + - complementary [ref=e37]: + - generic [ref=e38]: + - heading "Order Summary" [level=2] [ref=e39] + - generic [ref=e40]: + - generic [ref=e41]: + - generic [ref=e42]: Subtotal + - generic [ref=e43]: $0.00 + - generic [ref=e45]: + - generic [ref=e46]: Total + - generic [ref=e47]: $0.00 + - button "Continue to Checkout" [ref=e48] + - link "Continue Shopping" [ref=e49] [cursor=pointer]: + - /url: / + - generic [ref=e52]: + - generic [ref=e53]: + - paragraph [ref=e54]: Stay in the loop + - heading "Harvest updates, new stops & seasonal news" [level=3] [ref=e55] + - generic [ref=e56]: + - textbox "your@email.com" [ref=e57] + - button "Subscribe" [ref=e58] + - contentinfo [ref=e59]: + - generic [ref=e60]: + - generic [ref=e61]: + - generic [ref=e62]: + - paragraph [ref=e63]: Tuxedo Corn + - paragraph [ref=e64]: Fresh from the farm to your family. We grow and deliver premium produce with care you can taste and trust. + - generic [ref=e65]: + - link "Instagram" [ref=e66] [cursor=pointer]: + - /url: https://instagram.com + - img [ref=e67] + - link "Facebook" [ref=e69] [cursor=pointer]: + - /url: https://facebook.com + - img [ref=e70] + - generic [ref=e72]: + - paragraph [ref=e73]: Shop + - navigation [ref=e74]: + - link "Home" [ref=e75] [cursor=pointer]: + - /url: /tuxedo + - link "Upcoming Stops" [ref=e76] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e77] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e78] [cursor=pointer]: + - /url: /tuxedo/about + - generic [ref=e79]: + - paragraph [ref=e80]: Help + - navigation [ref=e81]: + - link "Cart" [ref=e82] [cursor=pointer]: + - /url: /cart + - link "Wholesale Portal" [ref=e83] [cursor=pointer]: + - /url: /wholesale/login + - link "Contact Us" [ref=e84] [cursor=pointer]: + - /url: /tuxedo/contact + - link "FAQ" [ref=e85] [cursor=pointer]: + - /url: /tuxedo/faq + - generic [ref=e86]: + - paragraph [ref=e87]: Fresh & Local + - navigation [ref=e88]: + - link "Our Farm" [ref=e89] [cursor=pointer]: + - /url: /tuxedo/about + - link "What's in Season" [ref=e90] [cursor=pointer]: + - /url: /tuxedo#products + - link "Visit Us" [ref=e91] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Sell With Us" [ref=e92] [cursor=pointer]: + - /url: /wholesale/register + - generic [ref=e93]: + - paragraph [ref=e94]: © 2026 Tuxedo Corn. All rights reserved. + - generic [ref=e95]: + - link "Privacy Policy" [ref=e96] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e97] [cursor=pointer]: + - /url: /terms-and-conditions + - contentinfo [ref=e98]: + - generic [ref=e100]: + - paragraph [ref=e101]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e102]: + - link "Privacy Policy" [ref=e103] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e104] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e105] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e106] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-11-53-269Z.yml b/.playwright-mcp/page-2026-05-26T05-11-53-269Z.yml new file mode 100644 index 0000000..83fb50d --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-11-53-269Z.yml @@ -0,0 +1,120 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - banner [ref=e3]: + - generic [ref=e4]: + - link "Indian River Direct" [ref=e5] [cursor=pointer]: + - /url: /indian-river-direct + - generic [ref=e6]: Indian River Direct + - navigation [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: /indian-river-direct + - link "Stops" [ref=e9] [cursor=pointer]: + - /url: /indian-river-direct#stops + - link "Products" [ref=e10] [cursor=pointer]: + - /url: /indian-river-direct#products + - link "Our Story" [ref=e11] [cursor=pointer]: + - /url: /indian-river-direct/about + - link "FAQ" [ref=e12] [cursor=pointer]: + - /url: /indian-river-direct/faq + - link "Contact" [ref=e13] [cursor=pointer]: + - /url: /indian-river-direct/contact + - link "Wholesale" [ref=e14] [cursor=pointer]: + - /url: /wholesale/login + - link "Cart" [ref=e16] [cursor=pointer]: + - /url: /cart + - main [ref=e17]: + - generic [ref=e19]: + - paragraph [ref=e20]: Indian River Direct + - heading "Indian River Direct" [level=1] [ref=e21] + - paragraph [ref=e22]: Fresh Florida citrus delivered to stops near you. + - generic [ref=e23]: + - button "Find Stops" [ref=e24] + - button "Shop Products" [ref=e25] + - generic [ref=e28]: + - generic [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: + - heading "Find Stops Near You" [level=2] [ref=e35] + - paragraph [ref=e36]: Search by ZIP code or city name + - generic [ref=e37]: + - generic [ref=e38]: + - generic: + - img + - textbox "ZIP code or city..." [ref=e39] + - button "Search" [ref=e40] + - generic [ref=e42]: + - generic [ref=e43]: + - generic [ref=e44]: + - heading "Upcoming Stops" [level=2] [ref=e45] + - paragraph [ref=e46]: Find a pickup stop near you and preorder fresh citrus. + - link "Download Schedule" [ref=e47] [cursor=pointer]: + - /url: /api/indian-river-direct/schedule-pdf + - img [ref=e48] + - text: Download Schedule + - paragraph [ref=e51]: No upcoming stops scheduled. Check back soon! + - generic [ref=e53]: + - generic [ref=e54]: + - heading "Fresh from the Grove" [level=2] [ref=e55] + - paragraph [ref=e56]: Hand-picked citrus and seasonal favorites, ready for pickup or shipment. + - paragraph [ref=e58]: No products available at this time. + - generic [ref=e61]: + - generic [ref=e62]: + - paragraph [ref=e63]: Stay in the loop + - heading "Harvest updates, new stops & seasonal news" [level=3] [ref=e64] + - generic [ref=e65]: + - textbox "your@email.com" [ref=e66] + - button "Subscribe" [ref=e67] + - contentinfo [ref=e68]: + - generic [ref=e69]: + - generic [ref=e70]: + - generic [ref=e71]: + - paragraph [ref=e72]: Indian River Direct + - paragraph [ref=e73]: Fresh from the farm to your family. We grow and deliver premium produce with care you can taste and trust. + - generic [ref=e74]: + - link "Instagram" [ref=e75] [cursor=pointer]: + - /url: https://instagram.com + - img [ref=e76] + - link "Facebook" [ref=e78] [cursor=pointer]: + - /url: https://facebook.com + - img [ref=e79] + - generic [ref=e81]: + - paragraph [ref=e82]: Shop + - navigation [ref=e83]: + - link "Home" [ref=e84] [cursor=pointer]: + - /url: /indian-river-direct + - link "Upcoming Stops" [ref=e85] [cursor=pointer]: + - /url: /indian-river-direct#stops + - link "Products" [ref=e86] [cursor=pointer]: + - /url: /indian-river-direct#products + - link "Our Story" [ref=e87] [cursor=pointer]: + - /url: /indian-river-direct/about + - generic [ref=e88]: + - paragraph [ref=e89]: Help + - navigation [ref=e90]: + - link "Cart" [ref=e91] [cursor=pointer]: + - /url: /cart + - link "Wholesale Portal" [ref=e92] [cursor=pointer]: + - /url: /wholesale/login + - link "Contact Us" [ref=e93] [cursor=pointer]: + - /url: /indian-river-direct/contact + - link "FAQ" [ref=e94] [cursor=pointer]: + - /url: /indian-river-direct/faq + - generic [ref=e95]: + - paragraph [ref=e96]: Fresh & Local + - navigation [ref=e97]: + - link "Our Farm" [ref=e98] [cursor=pointer]: + - /url: /indian-river-direct/about + - link "What's in Season" [ref=e99] [cursor=pointer]: + - /url: /indian-river-direct#products + - link "Visit Us" [ref=e100] [cursor=pointer]: + - /url: /indian-river-direct/contact + - link "Sell With Us" [ref=e101] [cursor=pointer]: + - /url: /wholesale/register + - generic [ref=e102]: + - paragraph [ref=e103]: © 2026 Indian River Direct. All rights reserved. + - generic [ref=e104]: + - link "Privacy Policy" [ref=e105] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e106] [cursor=pointer]: + - /url: /terms-and-conditions + - alert [ref=e107] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-26T05-12-06-752Z.yml b/.playwright-mcp/page-2026-05-26T05-12-06-752Z.yml new file mode 100644 index 0000000..bb6468d --- /dev/null +++ b/.playwright-mcp/page-2026-05-26T05-12-06-752Z.yml @@ -0,0 +1,45 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [ref=e21]: + - /placeholder: admin@example.com + - generic [ref=e22]: + - generic [ref=e23]: Password + - textbox "Password" [ref=e24]: + - /placeholder: •••••••• + - button "Sign In" [ref=e25] + - button "Forgot password?" [ref=e26] + - link "← Back to storefront" [ref=e27] [cursor=pointer]: + - /url: / + - contentinfo [ref=e28]: + - generic [ref=e30]: + - paragraph [ref=e31]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e32]: + - link "Privacy Policy" [ref=e33] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e34] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e35] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T16-13-29-423Z.yml b/.playwright-mcp/page-2026-05-27T16-13-29-423Z.yml new file mode 100644 index 0000000..bb6468d --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T16-13-29-423Z.yml @@ -0,0 +1,45 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [ref=e21]: + - /placeholder: admin@example.com + - generic [ref=e22]: + - generic [ref=e23]: Password + - textbox "Password" [ref=e24]: + - /placeholder: •••••••• + - button "Sign In" [ref=e25] + - button "Forgot password?" [ref=e26] + - link "← Back to storefront" [ref=e27] [cursor=pointer]: + - /url: / + - contentinfo [ref=e28]: + - generic [ref=e30]: + - paragraph [ref=e31]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e32]: + - link "Privacy Policy" [ref=e33] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e34] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e35] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T16-13-39-159Z.yml b/.playwright-mcp/page-2026-05-27T16-13-39-159Z.yml new file mode 100644 index 0000000..0c9b326 --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T16-13-39-159Z.yml @@ -0,0 +1 @@ +- generic [ref=e2]: "{\"received_cookies\":[],\"rc_auth_uid\":\"MISSING\",\"rc_uid\":\"MISSING\",\"raw_rc_auth_uid\":null}" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T16-13-51-469Z.yml b/.playwright-mcp/page-2026-05-27T16-13-51-469Z.yml new file mode 100644 index 0000000..bb6468d --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T16-13-51-469Z.yml @@ -0,0 +1,45 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [ref=e21]: + - /placeholder: admin@example.com + - generic [ref=e22]: + - generic [ref=e23]: Password + - textbox "Password" [ref=e24]: + - /placeholder: •••••••• + - button "Sign In" [ref=e25] + - button "Forgot password?" [ref=e26] + - link "← Back to storefront" [ref=e27] [cursor=pointer]: + - /url: / + - contentinfo [ref=e28]: + - generic [ref=e30]: + - paragraph [ref=e31]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e32]: + - link "Privacy Policy" [ref=e33] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e34] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e35] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T16-17-43-255Z.yml b/.playwright-mcp/page-2026-05-27T16-17-43-255Z.yml new file mode 100644 index 0000000..bb6468d --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T16-17-43-255Z.yml @@ -0,0 +1,45 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: + - heading "Admin Login" [level=1] [ref=e16] + - paragraph [ref=e17]: Sign in with your account. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [ref=e21]: + - /placeholder: admin@example.com + - generic [ref=e22]: + - generic [ref=e23]: Password + - textbox "Password" [ref=e24]: + - /placeholder: •••••••• + - button "Sign In" [ref=e25] + - button "Forgot password?" [ref=e26] + - link "← Back to storefront" [ref=e27] [cursor=pointer]: + - /url: / + - contentinfo [ref=e28]: + - generic [ref=e30]: + - paragraph [ref=e31]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e32]: + - link "Privacy Policy" [ref=e33] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e34] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e35] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-33-53-522Z.yml b/.playwright-mcp/page-2026-05-27T18-33-53-522Z.yml new file mode 100644 index 0000000..97df027 --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-33-53-522Z.yml @@ -0,0 +1,49 @@ +- generic [active] [ref=e1]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - generic [ref=e3]: + - banner [ref=e4]: + - link "Vercel logo": + - /url: /home + - button "Vercel Logo": + - img "Vercel Logo" + - navigation [ref=e5]: + - navigation [ref=e6]: + - link "Sign Up" [ref=e7] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-lv1ays6f9-kylemart88480s-projects.vercel.app%252Ftuxedo%26nonce%3D2b5d08cb24eead96b5923d92f3aeec1720cb65efefe6e15324ba6abb7a61d9ce + - paragraph [ref=e9]: Sign Up + - main [ref=e10]: + - generic [ref=e12]: + - heading "Log in to Vercel" [level=1] [ref=e15] + - generic [ref=e16]: + - generic [ref=e17]: + - textbox "Email Address" [ref=e19] + - button "Continue with Email" [ref=e21] [cursor=pointer]: + - generic [ref=e22]: Continue with Email + - generic [ref=e24]: + - button "Continue with Google" [ref=e25] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e34]: Continue with Google + - button "Continue with GitHub" [ref=e35] [cursor=pointer]: + - img [ref=e37] + - generic [ref=e41]: Continue with GitHub + - button "Continue with Apple" [ref=e42] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e47]: Continue with Apple + - button "Continue with SAML SSO" [ref=e49] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e53]: Continue with SAML SSO + - button "Continue with Passkey" [ref=e54] [cursor=pointer]: + - img [ref=e56] + - generic [ref=e58]: Continue with Passkey + - button "Show other options" [ref=e59] [cursor=pointer]: + - generic [ref=e60]: Show other options + - paragraph [ref=e62]: + - text: Don't have an account? + - link "Sign Up" [ref=e63] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-lv1ays6f9-kylemart88480s-projects.vercel.app%252Ftuxedo%26nonce%3D2b5d08cb24eead96b5923d92f3aeec1720cb65efefe6e15324ba6abb7a61d9ce + - generic [ref=e66]: + - link "Terms" [ref=e67] [cursor=pointer]: + - /url: /legal/terms + - link "Privacy Policy" [ref=e68] [cursor=pointer]: + - /url: /legal/privacy-policy \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-33-56-314Z.yml b/.playwright-mcp/page-2026-05-27T18-33-56-314Z.yml new file mode 100644 index 0000000..c6a72fd --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-33-56-314Z.yml @@ -0,0 +1,50 @@ +- generic [active] [ref=e1]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - generic [ref=e3]: + - banner [ref=e4]: + - link "Vercel logo": + - /url: /home + - button "Vercel Logo": + - img "Vercel Logo" + - navigation [ref=e5]: + - navigation [ref=e6]: + - link "Sign Up" [ref=e7] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-lv1ays6f9-kylemart88480s-projects.vercel.app%252Flogin%26nonce%3D2b5d08cb24eead96b5923d92f3aeec1720cb65efefe6e15324ba6abb7a61d9ce + - paragraph [ref=e9]: Sign Up + - main [ref=e10]: + - generic [ref=e12]: + - heading "Log in to Vercel" [level=1] [ref=e15] + - generic [ref=e16]: + - generic [ref=e17]: + - textbox "Email Address" [ref=e19] + - button "Continue with Email" [ref=e21] [cursor=pointer]: + - generic [ref=e22]: Continue with Email + - generic [ref=e24]: + - button "Continue with Google" [ref=e25] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e34]: Continue with Google + - button "Continue with GitHub" [ref=e35] [cursor=pointer]: + - img [ref=e37] + - generic [ref=e41]: Continue with GitHub + - button "Continue with Apple" [ref=e42] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e47]: Continue with Apple + - button "Continue with SAML SSO" [ref=e49] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e53]: Continue with SAML SSO + - button "Continue with Passkey" [ref=e54] [cursor=pointer]: + - img [ref=e56] + - generic [ref=e58]: Continue with Passkey + - button "Show other options" [ref=e59] [cursor=pointer]: + - generic [ref=e60]: Show other options + - paragraph [ref=e62]: + - text: Don't have an account? + - link "Sign Up" [ref=e63] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-lv1ays6f9-kylemart88480s-projects.vercel.app%252Flogin%26nonce%3D2b5d08cb24eead96b5923d92f3aeec1720cb65efefe6e15324ba6abb7a61d9ce + - generic [ref=e66]: + - link "Terms" [ref=e67] [cursor=pointer]: + - /url: /legal/terms + - link "Privacy Policy" [ref=e68] [cursor=pointer]: + - /url: /legal/privacy-policy + - alert [ref=e69] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-33-58-591Z.yml b/.playwright-mcp/page-2026-05-27T18-33-58-591Z.yml new file mode 100644 index 0000000..c9b29cc --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-33-58-591Z.yml @@ -0,0 +1,50 @@ +- generic [active] [ref=e1]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - generic [ref=e3]: + - banner [ref=e4]: + - link "Vercel logo": + - /url: /home + - button "Vercel Logo": + - img "Vercel Logo" + - navigation [ref=e5]: + - navigation [ref=e6]: + - link "Sign Up" [ref=e7] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-lv1ays6f9-kylemart88480s-projects.vercel.app%252F%26nonce%3D2b5d08cb24eead96b5923d92f3aeec1720cb65efefe6e15324ba6abb7a61d9ce + - paragraph [ref=e9]: Sign Up + - main [ref=e10]: + - generic [ref=e12]: + - heading "Log in to Vercel" [level=1] [ref=e15] + - generic [ref=e16]: + - generic [ref=e17]: + - textbox "Email Address" [ref=e19] + - button "Continue with Email" [ref=e21] [cursor=pointer]: + - generic [ref=e22]: Continue with Email + - generic [ref=e24]: + - button "Continue with Google" [ref=e25] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e34]: Continue with Google + - button "Continue with GitHub" [ref=e35] [cursor=pointer]: + - img [ref=e37] + - generic [ref=e41]: Continue with GitHub + - button "Continue with Apple" [ref=e42] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e47]: Continue with Apple + - button "Continue with SAML SSO" [ref=e49] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e53]: Continue with SAML SSO + - button "Continue with Passkey" [ref=e54] [cursor=pointer]: + - img [ref=e56] + - generic [ref=e58]: Continue with Passkey + - button "Show other options" [ref=e59] [cursor=pointer]: + - generic [ref=e60]: Show other options + - paragraph [ref=e62]: + - text: Don't have an account? + - link "Sign Up" [ref=e63] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-lv1ays6f9-kylemart88480s-projects.vercel.app%252F%26nonce%3D2b5d08cb24eead96b5923d92f3aeec1720cb65efefe6e15324ba6abb7a61d9ce + - generic [ref=e66]: + - link "Terms" [ref=e67] [cursor=pointer]: + - /url: /legal/terms + - link "Privacy Policy" [ref=e68] [cursor=pointer]: + - /url: /legal/privacy-policy + - alert [ref=e69] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-34-11-874Z.yml b/.playwright-mcp/page-2026-05-27T18-34-11-874Z.yml new file mode 100644 index 0000000..fe2e80b --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-34-11-874Z.yml @@ -0,0 +1,3 @@ +- generic [active]: + - link "Skip to content": + - /url: "#geist-skip-nav" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-34-30-126Z.yml b/.playwright-mcp/page-2026-05-27T18-34-30-126Z.yml new file mode 100644 index 0000000..1d20790 --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-34-30-126Z.yml @@ -0,0 +1,4 @@ +- generic [active]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - alert [ref=e1] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-34-48-461Z.yml b/.playwright-mcp/page-2026-05-27T18-34-48-461Z.yml new file mode 100644 index 0000000..fe2e80b --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-34-48-461Z.yml @@ -0,0 +1,3 @@ +- generic [active]: + - link "Skip to content": + - /url: "#geist-skip-nav" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-35-18-327Z.yml b/.playwright-mcp/page-2026-05-27T18-35-18-327Z.yml new file mode 100644 index 0000000..bfa01ec --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-35-18-327Z.yml @@ -0,0 +1,42 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - heading "Route Commerce Platform" [level=1] [ref=e15] + - paragraph [ref=e16]: A shared commerce and operations platform for Tuxedo Corn and Indian River Direct. + - generic [ref=e17]: + - link "Tuxedo Corn Tour stops, sweet corn orders, shipping, pickup, and onsite sales." [ref=e18] [cursor=pointer]: + - /url: /tuxedo + - heading "Tuxedo Corn" [level=2] [ref=e19] + - paragraph [ref=e20]: Tour stops, sweet corn orders, shipping, pickup, and onsite sales. + - link "Indian River Direct Citrus tours, ZIP code stop finder, orders, and customer communications." [ref=e21] [cursor=pointer]: + - /url: /indian-river-direct + - heading "Indian River Direct" [level=2] [ref=e22] + - paragraph [ref=e23]: Citrus tours, ZIP code stop finder, orders, and customer communications. + - link "Open Admin Dashboard" [ref=e24] [cursor=pointer]: + - /url: /admin + - contentinfo [ref=e25]: + - generic [ref=e27]: + - paragraph [ref=e28]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e29]: + - link "Privacy Policy" [ref=e30] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e31] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e32] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e33] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-35-25-016Z.yml b/.playwright-mcp/page-2026-05-27T18-35-25-016Z.yml new file mode 100644 index 0000000..748e771 --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-35-25-016Z.yml @@ -0,0 +1,185 @@ +- generic [active] [ref=e1]: + - alert [ref=e2] + - generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: + - link "Tuxedo Corn": + - /url: /tuxedo + - generic: + - img "Tuxedo Corn" + - navigation [ref=e6]: + - link "Home" [ref=e7] [cursor=pointer]: + - /url: /tuxedo + - link "Stops" [ref=e8] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e9] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e10] [cursor=pointer]: + - /url: /tuxedo/about + - link "FAQ" [ref=e11] [cursor=pointer]: + - /url: /tuxedo/faq + - link "Contact" [ref=e12] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Wholesale" [ref=e13] [cursor=pointer]: + - /url: /wholesale/login + - link "Cart" [ref=e15] [cursor=pointer]: + - /url: /cart + - main [ref=e16]: + - generic [ref=e20]: + - img "Olathe Sweet" [ref=e22] + - paragraph [ref=e23]: Olathe Sweet™ — Olathe, Colorado + - heading "Tuxedo Corn" [level=1] [ref=e24] + - paragraph [ref=e25]: Premium Sweet Corn, Farm-Direct to You + - generic [ref=e26]: + - button "Find a Stop" [ref=e27] + - button "Our Story" [ref=e28]: + - generic [ref=e29]: Our Story + - img [ref=e30] + - generic [ref=e34]: + - generic [ref=e35]: + - paragraph [ref=e36]: Why Olathe Sweet™ + - heading "Why Choose Tuxedo Corn" [level=2] [ref=e37] + - paragraph [ref=e39]: Colorado has trusted Olathe Sweet at their tables for over four decades. Here is why. + - generic [ref=e40]: + - generic [ref=e41]: + - img [ref=e43] + - paragraph [ref=e52]: Hand-Harvested + - heading "Picked by Hand, Every Ear" [level=3] [ref=e53] + - paragraph [ref=e54]: Three generations of careful hands move through every row. No machine knows when an ear is perfectly ripe the way a farmer's daughter does. + - generic [ref=e56]: + - img [ref=e58] + - paragraph [ref=e68]: Non-GMO + - heading "Seed to Field, Naturally" [level=3] [ref=e69] + - paragraph [ref=e70]: Our Olathe Sweet seed has never been touched by a laboratory. The flavor was shaped by forty summers of patient selection — nothing added, nothing altered. + - generic [ref=e72]: + - img [ref=e74] + - paragraph [ref=e78]: Colorado Grown + - heading "Born in the High Country" [level=3] [ref=e79] + - paragraph [ref=e80]: Olathe Sweet was developed for Colorado's mountain climate — intense sun by day, crisp nights by morning. That contrast makes a sweetness no other soil can claim. + - generic [ref=e82]: + - img [ref=e84] + - paragraph [ref=e89]: Regenerative + - heading "Our Roots Run Deep" [level=3] [ref=e90] + - paragraph [ref=e91]: We give back more than we take. Our soil is healthier today than forty years ago — not because we had to, but because this land deserves that kind of care. + - generic [ref=e93]: + - img [ref=e95] + - paragraph [ref=e106]: Peak Freshness + - heading "Harvested the Morning You Order" [level=3] [ref=e107] + - paragraph [ref=e108]: We pick before the heat steals a single calorie of sweetness. By the time your order arrives, the corn was still in the field that same morning. + - generic [ref=e110]: + - img [ref=e112] + - paragraph [ref=e116]: Grown in CO + - heading "Your Neighborhood Farm" [level=3] [ref=e117] + - paragraph [ref=e118]: Forty years of Colorado stops. We know every town we serve — when they like to pick up, where they gather. Your order isn't a transaction; it's a relationship. + - generic [ref=e120]: + - img [ref=e122] + - paragraph [ref=e127]: No Middleman + - heading "From Our Rows to Your Table" [level=3] [ref=e128] + - paragraph [ref=e129]: No distribution center. No supermarket back shelf. Just the field, the cooler, and your front door — the same day. That's the promise we keep. + - generic [ref=e131]: + - img [ref=e133] + - paragraph [ref=e139]: Easy Preorder + - heading "Reserve in 30 Seconds" [level=3] [ref=e140] + - paragraph [ref=e141]: Choose your stop, tap your order, done. We'll have your corn in a labeled cooler with your name on it — no account, no app, no confusion. + - paragraph [ref=e143]: Olathe Sweet™ · Olathe, Colorado + - generic [ref=e145]: + - generic [ref=e147]: + - paragraph [ref=e148]: Delivery Stops + - heading "Upcoming Stops" [level=2] [ref=e149]: + - text: Upcoming + - text: Stops + - paragraph [ref=e151]: Find a nearby stop and preorder your corn. Pickup is easy and guaranteed. + - paragraph [ref=e153]: No upcoming stops scheduled. Check back soon! + - generic [ref=e155]: + - generic [ref=e156]: + - paragraph [ref=e157]: Farm-Direct + - heading "This Week's Harvest" [level=2] [ref=e158]: + - text: This Week's + - text: Harvest + - paragraph [ref=e160]: Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season. + - paragraph [ref=e162]: No products available at this time. + - generic [ref=e165]: + - paragraph [ref=e166]: Our Story + - heading "Three Generations of Sweet Corn Excellence" [level=2] [ref=e167] + - paragraph [ref=e169]: Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn — developed for Colorado's high-altitude mountain climate and grown by the same family for over 40 years. + - generic [ref=e170]: + - generic [ref=e171]: + - paragraph [ref=e172]: 40+ + - paragraph [ref=e173]: Years + - generic [ref=e174]: + - paragraph [ref=e175]: "3" + - paragraph [ref=e176]: Generations + - generic [ref=e177]: + - paragraph [ref=e178]: 100% + - paragraph [ref=e179]: Hand-Picked + - link "Read Our Story" [ref=e180] [cursor=pointer]: + - /url: /tuxedo/about + - text: Read Our Story + - img [ref=e181] + - generic [ref=e185]: + - generic [ref=e186]: + - paragraph [ref=e187]: Stay in the loop + - heading "Harvest updates, new stops & seasonal news" [level=3] [ref=e188] + - generic [ref=e189]: + - textbox "your@email.com" [ref=e190] + - button "Subscribe" [ref=e191] + - contentinfo [ref=e192]: + - generic [ref=e193]: + - generic [ref=e194]: + - generic [ref=e195]: + - generic: + - img "Tuxedo Corn" + - paragraph [ref=e196]: Fresh from the farm to your family. We grow and deliver premium produce with care you can taste and trust. + - generic [ref=e197]: + - link "Instagram" [ref=e198] [cursor=pointer]: + - /url: https://instagram.com + - img [ref=e199] + - link "Facebook" [ref=e201] [cursor=pointer]: + - /url: https://facebook.com + - img [ref=e202] + - link "970-323-6874" [ref=e204] [cursor=pointer]: + - /url: tel:9703236874 + - link "info@tuxedofarmco.com" [ref=e205] [cursor=pointer]: + - /url: mailto:info@tuxedofarmco.com + - generic [ref=e206]: + - paragraph [ref=e207]: Shop + - navigation [ref=e208]: + - link "Home" [ref=e209] [cursor=pointer]: + - /url: /tuxedo + - link "Upcoming Stops" [ref=e210] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e211] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e212] [cursor=pointer]: + - /url: /tuxedo/about + - generic [ref=e213]: + - paragraph [ref=e214]: Help + - navigation [ref=e215]: + - link "Cart" [ref=e216] [cursor=pointer]: + - /url: /cart + - link "Wholesale Portal" [ref=e217] [cursor=pointer]: + - /url: /wholesale/login + - link "Contact Us" [ref=e218] [cursor=pointer]: + - /url: /tuxedo/contact + - link "FAQ" [ref=e219] [cursor=pointer]: + - /url: /tuxedo/faq + - generic [ref=e220]: + - paragraph [ref=e221]: Fresh & Local + - navigation [ref=e222]: + - link "Our Farm" [ref=e223] [cursor=pointer]: + - /url: /tuxedo/about + - link "What's in Season" [ref=e224] [cursor=pointer]: + - /url: /tuxedo#products + - link "Visit Us" [ref=e225] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Sell With Us" [ref=e226] [cursor=pointer]: + - /url: /wholesale/register + - generic [ref=e227]: + - paragraph [ref=e228]: © 2026 Tuxedo Corn. All rights reserved. + - generic [ref=e229]: + - link "Admin Dashboard" [ref=e230] [cursor=pointer]: + - /url: /admin + - link "Privacy Policy" [ref=e231] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e232] [cursor=pointer]: + - /url: /terms-and-conditions \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-35-31-386Z.yml b/.playwright-mcp/page-2026-05-27T18-35-31-386Z.yml new file mode 100644 index 0000000..b25176f --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-35-31-386Z.yml @@ -0,0 +1,182 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - banner [ref=e3]: + - generic [ref=e4]: + - link "Tuxedo Corn" [ref=e5] [cursor=pointer]: + - /url: /tuxedo + - generic [ref=e6]: Tuxedo Corn + - navigation [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: /tuxedo + - link "Stops" [ref=e9] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e10] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e11] [cursor=pointer]: + - /url: /tuxedo/about + - link "FAQ" [ref=e12] [cursor=pointer]: + - /url: /tuxedo/faq + - link "Contact" [ref=e13] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Wholesale" [ref=e14] [cursor=pointer]: + - /url: /wholesale/login + - link "Cart" [ref=e16] [cursor=pointer]: + - /url: /cart + - main [ref=e17]: + - generic [ref=e21]: + - img "Olathe Sweet" [ref=e23] + - paragraph [ref=e24]: Olathe Sweet™ — Olathe, Colorado + - heading "Tuxedo Corn" [level=1] [ref=e25] + - paragraph [ref=e26]: Premium Olathe Sweet™ sweet corn — hand-picked at peak freshness from our family farm in Colorado. + - generic [ref=e27]: + - button "Find a Stop" [ref=e28] + - button "Our Story" [ref=e29]: + - generic [ref=e30]: Our Story + - img [ref=e31] + - generic [ref=e35]: + - generic [ref=e36]: + - paragraph [ref=e37]: Why Olathe Sweet™ + - heading "Why Choose Tuxedo Corn" [level=2] [ref=e38] + - paragraph [ref=e40]: Colorado has trusted Olathe Sweet at their tables for over four decades. Here is why. + - generic [ref=e41]: + - generic [ref=e42]: + - img [ref=e44] + - paragraph [ref=e53]: Hand-Harvested + - heading "Picked by Hand, Every Ear" [level=3] [ref=e54] + - paragraph [ref=e55]: Three generations of careful hands move through every row. No machine knows when an ear is perfectly ripe the way a farmer's daughter does. + - generic [ref=e57]: + - img [ref=e59] + - paragraph [ref=e69]: Non-GMO + - heading "Seed to Field, Naturally" [level=3] [ref=e70] + - paragraph [ref=e71]: Our Olathe Sweet seed has never been touched by a laboratory. The flavor was shaped by forty summers of patient selection — nothing added, nothing altered. + - generic [ref=e73]: + - img [ref=e75] + - paragraph [ref=e79]: Colorado Grown + - heading "Born in the High Country" [level=3] [ref=e80] + - paragraph [ref=e81]: Olathe Sweet was developed for Colorado's mountain climate — intense sun by day, crisp nights by morning. That contrast makes a sweetness no other soil can claim. + - generic [ref=e83]: + - img [ref=e85] + - paragraph [ref=e90]: Regenerative + - heading "Our Roots Run Deep" [level=3] [ref=e91] + - paragraph [ref=e92]: We give back more than we take. Our soil is healthier today than forty years ago — not because we had to, but because this land deserves that kind of care. + - generic [ref=e94]: + - img [ref=e96] + - paragraph [ref=e107]: Peak Freshness + - heading "Harvested the Morning You Order" [level=3] [ref=e108] + - paragraph [ref=e109]: We pick before the heat steals a single calorie of sweetness. By the time your order arrives, the corn was still in the field that same morning. + - generic [ref=e111]: + - img [ref=e113] + - paragraph [ref=e117]: Grown in CO + - heading "Your Neighborhood Farm" [level=3] [ref=e118] + - paragraph [ref=e119]: Forty years of Colorado stops. We know every town we serve — when they like to pick up, where they gather. Your order isn't a transaction; it's a relationship. + - generic [ref=e121]: + - img [ref=e123] + - paragraph [ref=e128]: No Middleman + - heading "From Our Rows to Your Table" [level=3] [ref=e129] + - paragraph [ref=e130]: No distribution center. No supermarket back shelf. Just the field, the cooler, and your front door — the same day. That's the promise we keep. + - generic [ref=e132]: + - img [ref=e134] + - paragraph [ref=e140]: Easy Preorder + - heading "Reserve in 30 Seconds" [level=3] [ref=e141] + - paragraph [ref=e142]: Choose your stop, tap your order, done. We'll have your corn in a labeled cooler with your name on it — no account, no app, no confusion. + - paragraph [ref=e144]: Olathe Sweet™ · Olathe, Colorado + - generic [ref=e146]: + - generic [ref=e147]: + - generic [ref=e148]: + - paragraph [ref=e149]: Delivery Stops + - heading "Upcoming Stops" [level=2] [ref=e150]: + - text: Upcoming + - text: Stops + - paragraph [ref=e152]: Find a nearby stop and preorder your corn. Pickup is easy and guaranteed. + - link "Download Schedule" [ref=e153] [cursor=pointer]: + - /url: /api/tuxedo/schedule-pdf + - img [ref=e154] + - text: Download Schedule + - paragraph [ref=e157]: No upcoming stops scheduled. Check back soon! + - generic [ref=e159]: + - generic [ref=e160]: + - paragraph [ref=e161]: Farm-Direct + - heading "This Week's Harvest" [level=2] [ref=e162]: + - text: This Week's + - text: Harvest + - paragraph [ref=e164]: Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season. + - paragraph [ref=e166]: No products available at this time. + - generic [ref=e169]: + - paragraph [ref=e170]: Our Story + - heading "Three Generations of Sweet Corn Excellence" [level=2] [ref=e171] + - paragraph [ref=e173]: Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn — developed for Colorado's high-altitude mountain climate and grown by the same family for over 40 years. + - generic [ref=e174]: + - generic [ref=e175]: + - paragraph [ref=e176]: 40+ + - paragraph [ref=e177]: Years + - generic [ref=e178]: + - paragraph [ref=e179]: "3" + - paragraph [ref=e180]: Generations + - generic [ref=e181]: + - paragraph [ref=e182]: 100% + - paragraph [ref=e183]: Hand-Picked + - link "Read Our Story" [ref=e184] [cursor=pointer]: + - /url: /tuxedo/about + - text: Read Our Story + - img [ref=e185] + - generic [ref=e189]: + - generic [ref=e190]: + - paragraph [ref=e191]: Stay in the loop + - heading "Harvest updates, new stops & seasonal news" [level=3] [ref=e192] + - generic [ref=e193]: + - textbox "your@email.com" [ref=e194] + - button "Subscribe" [ref=e195] + - contentinfo [ref=e196]: + - generic [ref=e197]: + - generic [ref=e198]: + - generic [ref=e199]: + - paragraph [ref=e200]: Tuxedo Corn + - paragraph [ref=e201]: Fresh from the farm to your family. We grow and deliver premium produce with care you can taste and trust. + - generic [ref=e202]: + - link "Instagram" [ref=e203] [cursor=pointer]: + - /url: https://instagram.com + - img [ref=e204] + - link "Facebook" [ref=e206] [cursor=pointer]: + - /url: https://facebook.com + - img [ref=e207] + - generic [ref=e209]: + - paragraph [ref=e210]: Shop + - navigation [ref=e211]: + - link "Home" [ref=e212] [cursor=pointer]: + - /url: /tuxedo + - link "Upcoming Stops" [ref=e213] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e214] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e215] [cursor=pointer]: + - /url: /tuxedo/about + - generic [ref=e216]: + - paragraph [ref=e217]: Help + - navigation [ref=e218]: + - link "Cart" [ref=e219] [cursor=pointer]: + - /url: /cart + - link "Wholesale Portal" [ref=e220] [cursor=pointer]: + - /url: /wholesale/login + - link "Contact Us" [ref=e221] [cursor=pointer]: + - /url: /tuxedo/contact + - link "FAQ" [ref=e222] [cursor=pointer]: + - /url: /tuxedo/faq + - generic [ref=e223]: + - paragraph [ref=e224]: Fresh & Local + - navigation [ref=e225]: + - link "Our Farm" [ref=e226] [cursor=pointer]: + - /url: /tuxedo/about + - link "What's in Season" [ref=e227] [cursor=pointer]: + - /url: /tuxedo#products + - link "Visit Us" [ref=e228] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Sell With Us" [ref=e229] [cursor=pointer]: + - /url: /wholesale/register + - generic [ref=e230]: + - paragraph [ref=e231]: © 2026 Tuxedo Corn. All rights reserved. + - generic [ref=e232]: + - link "Privacy Policy" [ref=e233] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e234] [cursor=pointer]: + - /url: /terms-and-conditions + - alert [ref=e235] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-36-14-195Z.yml b/.playwright-mcp/page-2026-05-27T18-36-14-195Z.yml new file mode 100644 index 0000000..83fb50d --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-36-14-195Z.yml @@ -0,0 +1,120 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - banner [ref=e3]: + - generic [ref=e4]: + - link "Indian River Direct" [ref=e5] [cursor=pointer]: + - /url: /indian-river-direct + - generic [ref=e6]: Indian River Direct + - navigation [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: /indian-river-direct + - link "Stops" [ref=e9] [cursor=pointer]: + - /url: /indian-river-direct#stops + - link "Products" [ref=e10] [cursor=pointer]: + - /url: /indian-river-direct#products + - link "Our Story" [ref=e11] [cursor=pointer]: + - /url: /indian-river-direct/about + - link "FAQ" [ref=e12] [cursor=pointer]: + - /url: /indian-river-direct/faq + - link "Contact" [ref=e13] [cursor=pointer]: + - /url: /indian-river-direct/contact + - link "Wholesale" [ref=e14] [cursor=pointer]: + - /url: /wholesale/login + - link "Cart" [ref=e16] [cursor=pointer]: + - /url: /cart + - main [ref=e17]: + - generic [ref=e19]: + - paragraph [ref=e20]: Indian River Direct + - heading "Indian River Direct" [level=1] [ref=e21] + - paragraph [ref=e22]: Fresh Florida citrus delivered to stops near you. + - generic [ref=e23]: + - button "Find Stops" [ref=e24] + - button "Shop Products" [ref=e25] + - generic [ref=e28]: + - generic [ref=e29]: + - img [ref=e31] + - generic [ref=e34]: + - heading "Find Stops Near You" [level=2] [ref=e35] + - paragraph [ref=e36]: Search by ZIP code or city name + - generic [ref=e37]: + - generic [ref=e38]: + - generic: + - img + - textbox "ZIP code or city..." [ref=e39] + - button "Search" [ref=e40] + - generic [ref=e42]: + - generic [ref=e43]: + - generic [ref=e44]: + - heading "Upcoming Stops" [level=2] [ref=e45] + - paragraph [ref=e46]: Find a pickup stop near you and preorder fresh citrus. + - link "Download Schedule" [ref=e47] [cursor=pointer]: + - /url: /api/indian-river-direct/schedule-pdf + - img [ref=e48] + - text: Download Schedule + - paragraph [ref=e51]: No upcoming stops scheduled. Check back soon! + - generic [ref=e53]: + - generic [ref=e54]: + - heading "Fresh from the Grove" [level=2] [ref=e55] + - paragraph [ref=e56]: Hand-picked citrus and seasonal favorites, ready for pickup or shipment. + - paragraph [ref=e58]: No products available at this time. + - generic [ref=e61]: + - generic [ref=e62]: + - paragraph [ref=e63]: Stay in the loop + - heading "Harvest updates, new stops & seasonal news" [level=3] [ref=e64] + - generic [ref=e65]: + - textbox "your@email.com" [ref=e66] + - button "Subscribe" [ref=e67] + - contentinfo [ref=e68]: + - generic [ref=e69]: + - generic [ref=e70]: + - generic [ref=e71]: + - paragraph [ref=e72]: Indian River Direct + - paragraph [ref=e73]: Fresh from the farm to your family. We grow and deliver premium produce with care you can taste and trust. + - generic [ref=e74]: + - link "Instagram" [ref=e75] [cursor=pointer]: + - /url: https://instagram.com + - img [ref=e76] + - link "Facebook" [ref=e78] [cursor=pointer]: + - /url: https://facebook.com + - img [ref=e79] + - generic [ref=e81]: + - paragraph [ref=e82]: Shop + - navigation [ref=e83]: + - link "Home" [ref=e84] [cursor=pointer]: + - /url: /indian-river-direct + - link "Upcoming Stops" [ref=e85] [cursor=pointer]: + - /url: /indian-river-direct#stops + - link "Products" [ref=e86] [cursor=pointer]: + - /url: /indian-river-direct#products + - link "Our Story" [ref=e87] [cursor=pointer]: + - /url: /indian-river-direct/about + - generic [ref=e88]: + - paragraph [ref=e89]: Help + - navigation [ref=e90]: + - link "Cart" [ref=e91] [cursor=pointer]: + - /url: /cart + - link "Wholesale Portal" [ref=e92] [cursor=pointer]: + - /url: /wholesale/login + - link "Contact Us" [ref=e93] [cursor=pointer]: + - /url: /indian-river-direct/contact + - link "FAQ" [ref=e94] [cursor=pointer]: + - /url: /indian-river-direct/faq + - generic [ref=e95]: + - paragraph [ref=e96]: Fresh & Local + - navigation [ref=e97]: + - link "Our Farm" [ref=e98] [cursor=pointer]: + - /url: /indian-river-direct/about + - link "What's in Season" [ref=e99] [cursor=pointer]: + - /url: /indian-river-direct#products + - link "Visit Us" [ref=e100] [cursor=pointer]: + - /url: /indian-river-direct/contact + - link "Sell With Us" [ref=e101] [cursor=pointer]: + - /url: /wholesale/register + - generic [ref=e102]: + - paragraph [ref=e103]: © 2026 Indian River Direct. All rights reserved. + - generic [ref=e104]: + - link "Privacy Policy" [ref=e105] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e106] [cursor=pointer]: + - /url: /terms-and-conditions + - alert [ref=e107] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-36-27-792Z.yml b/.playwright-mcp/page-2026-05-27T18-36-27-792Z.yml new file mode 100644 index 0000000..3ec2463 --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-36-27-792Z.yml @@ -0,0 +1,10 @@ +- generic [active] [ref=e1]: + - alert [ref=e2] + - generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e5]: + - generic: + - img "Olathe Sweet" + - generic [ref=e6]: Tuxedo Corn + - button "ES" [ref=e7] + - generic [ref=e10]: Loading... \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-36-55-812Z.yml b/.playwright-mcp/page-2026-05-27T18-36-55-812Z.yml new file mode 100644 index 0000000..6194a9d --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-36-55-812Z.yml @@ -0,0 +1,214 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - button "Open sidebar" [ref=e13]: + - img [ref=e15] + - complementary [ref=e17]: + - link "Admin" [ref=e19] [cursor=pointer]: + - /url: /admin + - img [ref=e21] + - generic [ref=e23]: Admin + - navigation [ref=e24]: + - link "Dashboard" [ref=e25] [cursor=pointer]: + - /url: /admin + - img [ref=e27] + - text: Dashboard + - link "Orders" [ref=e29] [cursor=pointer]: + - /url: /admin/orders + - img [ref=e31] + - text: Orders + - link "Stops & Routes" [ref=e33] [cursor=pointer]: + - /url: /admin/stops + - img [ref=e35] + - text: Stops & Routes + - link "Products" [ref=e38] [cursor=pointer]: + - /url: /admin/products + - img [ref=e40] + - text: Products + - link "Time Tracking" [ref=e42] [cursor=pointer]: + - /url: /admin/time-tracking + - img [ref=e44] + - text: Time Tracking + - link "Communications" [ref=e46] [cursor=pointer]: + - /url: /admin/communications + - img [ref=e48] + - text: Communications + - button "Settings" [ref=e52]: + - generic [ref=e53]: + - img [ref=e54] + - text: Settings + - img [ref=e56] + - generic [ref=e58]: + - paragraph [ref=e59]: Platform Admin + - button "Sign out" [ref=e60] + - main [ref=e63]: + - generic [ref=e64]: + - generic [ref=e65]: + - paragraph [ref=e66]: Configuration + - heading "Settings" [level=1] [ref=e67] + - paragraph [ref=e68]: Manage your brand, workers, tasks, users, and integrations. + - generic [ref=e69]: + - link "General" [ref=e70] [cursor=pointer]: + - /url: "#general" + - generic [ref=e71]: · + - link "Workers & PINs" [ref=e72] [cursor=pointer]: + - /url: "#workers" + - generic [ref=e73]: · + - link "Tasks" [ref=e74] [cursor=pointer]: + - /url: "#tasks" + - generic [ref=e75]: · + - link "Users & Permissions" [ref=e76] [cursor=pointer]: + - /url: "#users" + - generic [ref=e77]: · + - link "Integrations" [ref=e78] [cursor=pointer]: + - /url: "#integrations" + - generic [ref=e81]: + - img [ref=e82] + - text: Loading settings... + - generic [ref=e86]: + - heading "Users & Permissions" [level=2] [ref=e87] + - generic [ref=e88]: + - heading "2 users" [level=2] [ref=e90] + - button "+ Create User" [ref=e91] + - table [ref=e93]: + - rowgroup [ref=e94]: + - row "Name Phone Role Brand Status Created" [ref=e95]: + - columnheader "Name" [ref=e96] + - columnheader "Phone" [ref=e97] + - columnheader "Role" [ref=e98] + - columnheader "Brand" [ref=e99] + - columnheader "Status" [ref=e100] + - columnheader "Created" [ref=e101] + - columnheader [ref=e102] + - rowgroup [ref=e103]: + - row "Erik Westesen erik@tuxedofarmco.com 970-596-4506 platform admin — Active 05/25/2026 Edit ⁝" [ref=e104]: + - cell "Erik Westesen erik@tuxedofarmco.com" [ref=e105]: + - generic [ref=e106]: Erik Westesen + - generic [ref=e107]: erik@tuxedofarmco.com + - cell "970-596-4506" [ref=e108] + - cell "platform admin" [ref=e109] + - cell "—" [ref=e110] + - cell "Active" [ref=e111] + - cell "05/25/2026" [ref=e112] + - cell "Edit ⁝" [ref=e113]: + - generic [ref=e114]: + - button "Edit" [ref=e115] + - button "⁝" [ref=e117] + - row "Kyle Martinez kylemart@gmail.com 9707731989 platform admin — Active 05/21/2026 Edit ⁝" [ref=e118]: + - cell "Kyle Martinez kylemart@gmail.com" [ref=e119]: + - generic [ref=e120]: Kyle Martinez + - generic [ref=e121]: kylemart@gmail.com + - cell "9707731989" [ref=e122] + - cell "platform admin" [ref=e123] + - cell "—" [ref=e124] + - cell "Active" [ref=e125] + - cell "05/21/2026" [ref=e126] + - cell "Edit ⁝" [ref=e127]: + - generic [ref=e128]: + - button "Edit" [ref=e129] + - button "⁝" [ref=e131] + - generic [ref=e133]: + - heading "Integrations & Exports" [level=2] [ref=e134] + - generic [ref=e135]: + - generic [ref=e137]: + - heading "AI Provider" [level=3] [ref=e138] + - link "Configure AI →" [ref=e139] [cursor=pointer]: + - /url: /admin/settings/ai + - generic [ref=e149]: + - generic [ref=e150]: + - generic [ref=e152]: + - generic [ref=e153]: Email + - generic [ref=e154]: + - heading "Resend" [level=3] [ref=e155] + - paragraph [ref=e156]: Send transactional and marketing emails via Harvest Reach. + - generic [ref=e157]: + - generic [ref=e158]: + - generic [ref=e159]: API Key + - generic [ref=e160]: + - textbox "re_..." [ref=e161] + - button "Show" [ref=e163] + - generic [ref=e164]: + - generic [ref=e165]: From Email Address + - textbox "orders@yourbrand.com" [ref=e167] + - generic [ref=e168]: + - generic [ref=e169]: From Name + - textbox "Your Brand Name" [ref=e171] + - generic [ref=e172]: + - button "Test Connection" [ref=e173] + - button "Save" [ref=e174] + - generic [ref=e175]: + - generic [ref=e177]: + - generic [ref=e178]: Stripe + - generic [ref=e179]: + - heading "Stripe" [level=3] [ref=e180] + - paragraph [ref=e181]: Process online payments for orders. + - generic [ref=e182]: + - generic [ref=e183]: + - generic [ref=e184]: Publishable Key + - textbox "pk_live_..." [ref=e186] + - generic [ref=e187]: + - generic [ref=e188]: Secret Key + - generic [ref=e189]: + - textbox "sk_live_..." [ref=e190] + - button "Show" [ref=e192] + - generic [ref=e193]: + - generic [ref=e194]: Webhook Secret + - generic [ref=e195]: + - textbox "whsec_..." [ref=e196] + - button "Show" [ref=e198] + - generic [ref=e199]: + - button "Test Connection" [ref=e200] + - button "Save" [ref=e201] + - generic [ref=e202]: + - generic [ref=e204]: + - generic [ref=e205]: SMS + - generic [ref=e206]: + - heading "Twilio" [level=3] [ref=e207] + - paragraph [ref=e208]: Send SMS campaigns and alerts via Harvest Reach. + - generic [ref=e209]: + - generic [ref=e210]: + - generic [ref=e211]: Account SID + - textbox "AC..." [ref=e213] + - generic [ref=e214]: + - generic [ref=e215]: Auth Token + - generic [ref=e216]: + - textbox "Your Twilio auth token" [ref=e217] + - button "Show" [ref=e219] + - generic [ref=e220]: + - generic [ref=e221]: Phone Number + - textbox "+1234567890" [ref=e223] + - generic [ref=e224]: + - button "Test Connection" [ref=e225] + - button "Save" [ref=e226] + - paragraph [ref=e227]: + - text: For more settings, see + - link "AI Tools" [ref=e228] [cursor=pointer]: + - /url: /admin/settings/ai + - text: and + - link "Shipping" [ref=e229] [cursor=pointer]: + - /url: /admin/settings/shipping + - text: . + - contentinfo [ref=e230]: + - generic [ref=e232]: + - paragraph [ref=e233]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e234]: + - link "Privacy Policy" [ref=e235] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e236] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e237] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e238] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-39-38-878Z.yml b/.playwright-mcp/page-2026-05-27T18-39-38-878Z.yml new file mode 100644 index 0000000..1d20790 --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-39-38-878Z.yml @@ -0,0 +1,4 @@ +- generic [active]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - alert [ref=e1] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-39-42-252Z.yml b/.playwright-mcp/page-2026-05-27T18-39-42-252Z.yml new file mode 100644 index 0000000..b25176f --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-39-42-252Z.yml @@ -0,0 +1,182 @@ +- generic [active] [ref=e1]: + - generic [ref=e2]: + - banner [ref=e3]: + - generic [ref=e4]: + - link "Tuxedo Corn" [ref=e5] [cursor=pointer]: + - /url: /tuxedo + - generic [ref=e6]: Tuxedo Corn + - navigation [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: /tuxedo + - link "Stops" [ref=e9] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e10] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e11] [cursor=pointer]: + - /url: /tuxedo/about + - link "FAQ" [ref=e12] [cursor=pointer]: + - /url: /tuxedo/faq + - link "Contact" [ref=e13] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Wholesale" [ref=e14] [cursor=pointer]: + - /url: /wholesale/login + - link "Cart" [ref=e16] [cursor=pointer]: + - /url: /cart + - main [ref=e17]: + - generic [ref=e21]: + - img "Olathe Sweet" [ref=e23] + - paragraph [ref=e24]: Olathe Sweet™ — Olathe, Colorado + - heading "Tuxedo Corn" [level=1] [ref=e25] + - paragraph [ref=e26]: Premium Olathe Sweet™ sweet corn — hand-picked at peak freshness from our family farm in Colorado. + - generic [ref=e27]: + - button "Find a Stop" [ref=e28] + - button "Our Story" [ref=e29]: + - generic [ref=e30]: Our Story + - img [ref=e31] + - generic [ref=e35]: + - generic [ref=e36]: + - paragraph [ref=e37]: Why Olathe Sweet™ + - heading "Why Choose Tuxedo Corn" [level=2] [ref=e38] + - paragraph [ref=e40]: Colorado has trusted Olathe Sweet at their tables for over four decades. Here is why. + - generic [ref=e41]: + - generic [ref=e42]: + - img [ref=e44] + - paragraph [ref=e53]: Hand-Harvested + - heading "Picked by Hand, Every Ear" [level=3] [ref=e54] + - paragraph [ref=e55]: Three generations of careful hands move through every row. No machine knows when an ear is perfectly ripe the way a farmer's daughter does. + - generic [ref=e57]: + - img [ref=e59] + - paragraph [ref=e69]: Non-GMO + - heading "Seed to Field, Naturally" [level=3] [ref=e70] + - paragraph [ref=e71]: Our Olathe Sweet seed has never been touched by a laboratory. The flavor was shaped by forty summers of patient selection — nothing added, nothing altered. + - generic [ref=e73]: + - img [ref=e75] + - paragraph [ref=e79]: Colorado Grown + - heading "Born in the High Country" [level=3] [ref=e80] + - paragraph [ref=e81]: Olathe Sweet was developed for Colorado's mountain climate — intense sun by day, crisp nights by morning. That contrast makes a sweetness no other soil can claim. + - generic [ref=e83]: + - img [ref=e85] + - paragraph [ref=e90]: Regenerative + - heading "Our Roots Run Deep" [level=3] [ref=e91] + - paragraph [ref=e92]: We give back more than we take. Our soil is healthier today than forty years ago — not because we had to, but because this land deserves that kind of care. + - generic [ref=e94]: + - img [ref=e96] + - paragraph [ref=e107]: Peak Freshness + - heading "Harvested the Morning You Order" [level=3] [ref=e108] + - paragraph [ref=e109]: We pick before the heat steals a single calorie of sweetness. By the time your order arrives, the corn was still in the field that same morning. + - generic [ref=e111]: + - img [ref=e113] + - paragraph [ref=e117]: Grown in CO + - heading "Your Neighborhood Farm" [level=3] [ref=e118] + - paragraph [ref=e119]: Forty years of Colorado stops. We know every town we serve — when they like to pick up, where they gather. Your order isn't a transaction; it's a relationship. + - generic [ref=e121]: + - img [ref=e123] + - paragraph [ref=e128]: No Middleman + - heading "From Our Rows to Your Table" [level=3] [ref=e129] + - paragraph [ref=e130]: No distribution center. No supermarket back shelf. Just the field, the cooler, and your front door — the same day. That's the promise we keep. + - generic [ref=e132]: + - img [ref=e134] + - paragraph [ref=e140]: Easy Preorder + - heading "Reserve in 30 Seconds" [level=3] [ref=e141] + - paragraph [ref=e142]: Choose your stop, tap your order, done. We'll have your corn in a labeled cooler with your name on it — no account, no app, no confusion. + - paragraph [ref=e144]: Olathe Sweet™ · Olathe, Colorado + - generic [ref=e146]: + - generic [ref=e147]: + - generic [ref=e148]: + - paragraph [ref=e149]: Delivery Stops + - heading "Upcoming Stops" [level=2] [ref=e150]: + - text: Upcoming + - text: Stops + - paragraph [ref=e152]: Find a nearby stop and preorder your corn. Pickup is easy and guaranteed. + - link "Download Schedule" [ref=e153] [cursor=pointer]: + - /url: /api/tuxedo/schedule-pdf + - img [ref=e154] + - text: Download Schedule + - paragraph [ref=e157]: No upcoming stops scheduled. Check back soon! + - generic [ref=e159]: + - generic [ref=e160]: + - paragraph [ref=e161]: Farm-Direct + - heading "This Week's Harvest" [level=2] [ref=e162]: + - text: This Week's + - text: Harvest + - paragraph [ref=e164]: Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season. + - paragraph [ref=e166]: No products available at this time. + - generic [ref=e169]: + - paragraph [ref=e170]: Our Story + - heading "Three Generations of Sweet Corn Excellence" [level=2] [ref=e171] + - paragraph [ref=e173]: Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn — developed for Colorado's high-altitude mountain climate and grown by the same family for over 40 years. + - generic [ref=e174]: + - generic [ref=e175]: + - paragraph [ref=e176]: 40+ + - paragraph [ref=e177]: Years + - generic [ref=e178]: + - paragraph [ref=e179]: "3" + - paragraph [ref=e180]: Generations + - generic [ref=e181]: + - paragraph [ref=e182]: 100% + - paragraph [ref=e183]: Hand-Picked + - link "Read Our Story" [ref=e184] [cursor=pointer]: + - /url: /tuxedo/about + - text: Read Our Story + - img [ref=e185] + - generic [ref=e189]: + - generic [ref=e190]: + - paragraph [ref=e191]: Stay in the loop + - heading "Harvest updates, new stops & seasonal news" [level=3] [ref=e192] + - generic [ref=e193]: + - textbox "your@email.com" [ref=e194] + - button "Subscribe" [ref=e195] + - contentinfo [ref=e196]: + - generic [ref=e197]: + - generic [ref=e198]: + - generic [ref=e199]: + - paragraph [ref=e200]: Tuxedo Corn + - paragraph [ref=e201]: Fresh from the farm to your family. We grow and deliver premium produce with care you can taste and trust. + - generic [ref=e202]: + - link "Instagram" [ref=e203] [cursor=pointer]: + - /url: https://instagram.com + - img [ref=e204] + - link "Facebook" [ref=e206] [cursor=pointer]: + - /url: https://facebook.com + - img [ref=e207] + - generic [ref=e209]: + - paragraph [ref=e210]: Shop + - navigation [ref=e211]: + - link "Home" [ref=e212] [cursor=pointer]: + - /url: /tuxedo + - link "Upcoming Stops" [ref=e213] [cursor=pointer]: + - /url: /tuxedo#stops + - link "Products" [ref=e214] [cursor=pointer]: + - /url: /tuxedo#products + - link "Our Story" [ref=e215] [cursor=pointer]: + - /url: /tuxedo/about + - generic [ref=e216]: + - paragraph [ref=e217]: Help + - navigation [ref=e218]: + - link "Cart" [ref=e219] [cursor=pointer]: + - /url: /cart + - link "Wholesale Portal" [ref=e220] [cursor=pointer]: + - /url: /wholesale/login + - link "Contact Us" [ref=e221] [cursor=pointer]: + - /url: /tuxedo/contact + - link "FAQ" [ref=e222] [cursor=pointer]: + - /url: /tuxedo/faq + - generic [ref=e223]: + - paragraph [ref=e224]: Fresh & Local + - navigation [ref=e225]: + - link "Our Farm" [ref=e226] [cursor=pointer]: + - /url: /tuxedo/about + - link "What's in Season" [ref=e227] [cursor=pointer]: + - /url: /tuxedo#products + - link "Visit Us" [ref=e228] [cursor=pointer]: + - /url: /tuxedo/contact + - link "Sell With Us" [ref=e229] [cursor=pointer]: + - /url: /wholesale/register + - generic [ref=e230]: + - paragraph [ref=e231]: © 2026 Tuxedo Corn. All rights reserved. + - generic [ref=e232]: + - link "Privacy Policy" [ref=e233] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e234] [cursor=pointer]: + - /url: /terms-and-conditions + - alert [ref=e235] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-39-47-481Z.yml b/.playwright-mcp/page-2026-05-27T18-39-47-481Z.yml new file mode 100644 index 0000000..1d20790 --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-39-47-481Z.yml @@ -0,0 +1,4 @@ +- generic [active]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - alert [ref=e1] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-42-55-676Z.yml b/.playwright-mcp/page-2026-05-27T18-42-55-676Z.yml new file mode 100644 index 0000000..18fcba8 --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-42-55-676Z.yml @@ -0,0 +1,55 @@ +- generic [active] [ref=e1]: + - link "Skip to content": + - /url: "#geist-skip-nav" + - generic [ref=e3]: + - banner [ref=e4]: + - link "Vercel logo": + - /url: /home + - button "Vercel Logo": + - img "Vercel Logo" + - navigation [ref=e5]: + - navigation [ref=e6]: + - link "Sign Up" [ref=e7] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-kvbpzunw3-kylemart88480s-projects.vercel.app%252Fadmin%252Fsettings%26nonce%3D1e85beea6dbe632f9afc71cd72d917ee726a02eca7cd686060e9282a8877645a + - paragraph [ref=e9]: Sign Up + - main [ref=e10]: + - generic [ref=e12]: + - heading "Log in to Vercel" [level=1] [ref=e15] + - generic [ref=e16]: + - generic [ref=e17]: + - textbox "Email Address" [ref=e19] + - button "Continue with Email" [ref=e21] [cursor=pointer]: + - generic [ref=e22]: Continue with Email + - generic [ref=e24]: + - button "Continue with Google" [ref=e25] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e34]: Continue with Google + - button "Continue with GitHub" [ref=e35] [cursor=pointer]: + - img [ref=e37] + - generic [ref=e41]: Continue with GitHub + - button "Continue with Apple" [ref=e42] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e47]: Continue with Apple + - button "Continue with SAML SSO" [ref=e49] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e53]: Continue with SAML SSO + - button "Continue with Passkey" [ref=e54] [cursor=pointer]: + - img [ref=e56] + - generic [ref=e58]: Continue with Passkey + - button "Show other options" [ref=e59] [cursor=pointer]: + - generic [ref=e60]: Show other options + - paragraph [ref=e62]: + - text: Don't have an account? + - link "Sign Up" [ref=e63] [cursor=pointer]: + - /url: /signup?next=%2Fsso-api%3Furl%3Dhttps%253A%252F%252Froute-commerce-platform-kvbpzunw3-kylemart88480s-projects.vercel.app%252Fadmin%252Fsettings%26nonce%3D1e85beea6dbe632f9afc71cd72d917ee726a02eca7cd686060e9282a8877645a + - generic [ref=e66]: + - link "Terms" [ref=e67] [cursor=pointer]: + - /url: /legal/terms + - link "Privacy Policy" [ref=e68] [cursor=pointer]: + - /url: /legal/privacy-policy + - alert [ref=e69] + - generic: + - generic: + - generic: + - generic: + - img \ No newline at end of file diff --git a/.playwright-mcp/page-2026-05-27T18-43-59-858Z.yml b/.playwright-mcp/page-2026-05-27T18-43-59-858Z.yml new file mode 100644 index 0000000..bfa01ec --- /dev/null +++ b/.playwright-mcp/page-2026-05-27T18-43-59-858Z.yml @@ -0,0 +1,42 @@ +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Route Commerce" [ref=e4] [cursor=pointer]: + - /url: / + - navigation [ref=e5]: + - link "Tuxedo" [ref=e6] [cursor=pointer]: + - /url: /tuxedo + - link "IRD" [ref=e7] [cursor=pointer]: + - /url: /indian-river-direct + - link "Admin" [ref=e8] [cursor=pointer]: + - /url: /admin + - button "Switch to dark mode" [ref=e9]: + - img [ref=e10] + - link "Cart (0)" [ref=e12] [cursor=pointer]: + - /url: /cart + - main [ref=e13]: + - generic [ref=e14]: + - heading "Route Commerce Platform" [level=1] [ref=e15] + - paragraph [ref=e16]: A shared commerce and operations platform for Tuxedo Corn and Indian River Direct. + - generic [ref=e17]: + - link "Tuxedo Corn Tour stops, sweet corn orders, shipping, pickup, and onsite sales." [ref=e18] [cursor=pointer]: + - /url: /tuxedo + - heading "Tuxedo Corn" [level=2] [ref=e19] + - paragraph [ref=e20]: Tour stops, sweet corn orders, shipping, pickup, and onsite sales. + - link "Indian River Direct Citrus tours, ZIP code stop finder, orders, and customer communications." [ref=e21] [cursor=pointer]: + - /url: /indian-river-direct + - heading "Indian River Direct" [level=2] [ref=e22] + - paragraph [ref=e23]: Citrus tours, ZIP code stop finder, orders, and customer communications. + - link "Open Admin Dashboard" [ref=e24] [cursor=pointer]: + - /url: /admin + - contentinfo [ref=e25]: + - generic [ref=e27]: + - paragraph [ref=e28]: © 2026 Route Commerce. All rights reserved. + - navigation [ref=e29]: + - link "Privacy Policy" [ref=e30] [cursor=pointer]: + - /url: /privacy-policy + - link "Terms & Conditions" [ref=e31] [cursor=pointer]: + - /url: /terms-and-conditions + - link "Powered by Route Commerce" [ref=e32] [cursor=pointer]: + - /url: https://cielohermosa.com + - alert [ref=e33] \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e01023d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,242 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns. + +Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4 + +--- + +## Commands + +```bash +npm run dev # Start dev server (runs fix-agents.js first to patch Next.js App Router agent issues) +npm run build # Production build +npm run lint # ESLint +npm run migrate:one # Push a migration (pass digits only, e.g. `npm run migrate:one 83` runs 083_*.sql) +npx tsc --noEmit # TypeScript check (no emit) +npx playwright test # Run E2E tests (Playwright) +``` + +> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL. +> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp` +> For direct PG mode: `pg` and `dotenv` are already in devDependencies. +> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first. + +No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`). + +--- + +## Architecture + +### Authentication & Authorization + +**Dev mode** bypasses Supabase auth entirely via `dev_session` cookie set by `/login`: +- `dev_session=platform_admin` — full access, all brands +- `dev_session=brand_admin` — full access to assigned brand only +- `dev_session=store_employee` — limited access (orders, pickup, wholesale only) + +`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and Supabase Auth in production. **Never import this file directly into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead. + +The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary. + +### Server Actions Pattern + +All database writes go through server actions in `src/actions/`. These: +1. Call `getAdminUser()` to verify auth +2. Check role/permission flags (`can_manage_orders`, etc.) +3. Call Supabase REST APIs (not the Supabase client directly in server actions) to trigger SECURITY DEFINER RPCs +4. Return typed results (`{ success: true, ... } | { success: false, error: string }`) + +Server actions are "use server" files that export async functions. Client components import and call them directly. + +### SECURITY DEFINER RPCs + Brand Scoping + +The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass RLS entirely. This means: + +- Brand isolation must be enforced at the **application layer** (in server actions), not in database policies +- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it +- The pattern in server actions: `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` — use explicitly passed `brandId` when available (from page server components), falling back to the admin's assigned brand + +**Critical consequence**: When `getAdminUser()` returns `brand_id: null` (e.g., dev_session platform_admin or certain dev auth states), `p_brand_id` passed to RPCs will be null. The RPCs handle this by returning all brands' data for platform_admin scope. + +### Brand ID Threading + +Pages under `/admin/` are server components that resolve `brandId` from `getAdminUser()` and pass it as a prop to client components. **Always thread `brandId` through the entire component tree** — never use hardcoded `BRAND_ID` constants in client components that accept a `brandId` prop. The pattern: + +```tsx +// admin/orders/page.tsx (server component) +const adminUser = await getAdminUser(); +const effectiveBrandId = adminUser.brand_id ?? undefined; // platform_admin gets all brands +return ; + +// OrdersPanel.tsx (client component) — use brandId prop, not constants +``` + +### Public Storefront Architecture + +Brand storefronts (`/tuxedo/*`, `/indian-river-direct/*`) are isolated from the platform shell. The root `layout.tsx` detects brand routes via `usePathname()` and suppresses `SiteHeader`/`SiteFooter` for those paths. Brand pages use `StorefrontHeader`/`StorefrontFooter` directly. + +Brand settings (hero tagline, about headline, feature toggles, etc.) are stored in `brand_settings` and fetched publicly via `getBrandSettingsPublic(brandSlug)` server action (no auth required). The `get_brand_settings_by_slug` RPC merges `brand_settings` + `wholesale_settings.wholesale_enabled` in one call. + +The `StorefrontHeader` and `StorefrontFooter` components accept: +```typescript +StorefrontHeader: { brandName, brandSlug, logoUrl?, showWholesaleLink?, isAdmin? } +StorefrontFooter: { brandName, brandSlug, logoUrl?, customFooterText?, contactEmail?, contactPhone?, isAdmin? } +``` + +### Feature Flags + Add-ons + +Add-on features are managed via the `brand_features` table and `src/lib/feature-flags.ts`. The `ADDON_CATALOG` defines all available add-ons. Check `isFeatureEnabled(brandId, key)` in server components. Toggle via `toggleBrandFeature(brandId, key, enabled)` in `src/actions/settings/features.ts`. The `/admin/settings/apps` page provides a UI for enabling/disabling add-ons per brand. + +### Billing + Plan Tiers + +Each brand has a `plan_tier` in the `brands` table: `starter` | `farm` | `enterprise`. Plan limits (max_users, max_stops_monthly, max_products) are also stored on the brand. Usage is computed live from actual table counts via `get_brand_plan_info` RPC. + +| Tier | Price | Includes | +|---|---|---| +| **Starter** | $49/mo | Products, Stops (10/mo), Orders, Basic Pickup, 1 user, 25 products | +| **Farm** | $149/mo | Everything in Starter + Wholesale Portal, Harvest Reach, unlimited stops/products, 5 users, priority support | +| **Enterprise** | Custom | Everything in Farm + AI Intelligence Pack, SMS Campaigns, Square Sync, Water Log, unlimited users, unlimited brands, custom development, dedicated SLA | + +Add-ons are available à la carte on any plan: `wholesale_portal` ($99/mo), `harvest_reach` ($79/mo), `ai_tools` ($59/mo), `water_log` ($39/mo), `square_sync` ($39/mo), `sms_campaigns` ($29/mo). + +Billing page: `/admin/settings/billing` — shows plan tier, usage stats, enabled add-ons, Stripe Customer Portal link (if `stripe_customer_id` set), and platform invoice history. + +### Stripe Billing Integration + +Real Stripe subscription management is integrated via: +- **`src/actions/billing/stripe-checkout.ts`** — `createStripeCheckoutSession`, `createPlanUpgradeCheckout`, `createAddonCheckoutSession`, `cancelAddonSubscription` +- **`src/app/api/stripe/webhook/route.ts`** — handles `checkout.session.completed`, `customer.subscription.updated/deleted`, `invoice.payment_succeeded/failed` + +When a subscription checkout completes, the webhook automatically: +1. Saves `stripe_subscription_id`, `stripe_subscription_status`, and `stripe_current_period_end` to the brand via `set_brand_subscription` RPC +2. Enables the corresponding feature flag via `set_brand_feature` for add-ons +3. Updates plan tier via `update_brand_plan_tier` for plan changes + +#### Required Stripe Credentials + +**Environment variables** (`.env.local` for local, hosting dashboard for production): + +| Variable | Format | Where to get | +|---|---|---| +| `STRIPE_SECRET_KEY` | `sk_live_...` or `sk_test_...` | Stripe Dashboard → Developers → API Keys → Secret key | +| `STRIPE_PUBLISHABLE_KEY` | `pk_live_...` or `pk_test_...` | Same page — used for Stripe.js in client components | +| `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Stripe Dashboard → Developers → Webhooks → select endpoint → Signing secret | + +**Stripe Price IDs** (create products/prices in Stripe Dashboard first): + +| Variable | Product to create in Stripe | +|---|---| +| `STRIPE_PRICE_STARTER` | Recurring $49/mo — Starter plan | +| `STRIPE_PRICE_FARM` | Recurring $149/mo — Farm plan | +| `STRIPE_PRICE_ENTERPRISE` | Recurring $399/mo — Enterprise plan | +| `STRIPE_PRICE_HARVEST_REACH` | Recurring $79/mo — Harvest Reach add-on | +| `STRIPE_PRICE_WHOLESALE_PORTAL` | Recurring $99/mo — Wholesale Portal add-on | +| `STRIPE_PRICE_WATER_LOG` | Recurring $39/mo — Water Log add-on | +| `STRIPE_PRICE_AI_TOOLS` | Recurring $59/mo — AI Intelligence add-on | +| `STRIPE_PRICE_SQUARE_SYNC` | Recurring $39/mo — Square Sync add-on | +| `STRIPE_PRICE_SMS_CAMPAIGNS` | Recurring $29/mo — SMS Campaigns add-on | + +For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for Starter = $49/mo * 12 * 0.75) and use different price IDs. Pass `annual=true` to `createStripeCheckoutSession` to select the annual price key. + +**Webhook setup** (Stripe Dashboard → Developers → Webhooks → Add endpoint): +- URL: `https://yourdomain.com/api/stripe/webhook` +- Events to listen for: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed` + +**UI Payments settings** (`/admin/settings/payments`): +- The `stripe_customer_id` on the brand record is set when the brand connects Stripe via the Payments settings UI (OAuth flow or manual key entry). Once set, billing checkout sessions can be created for that brand. + +### Adding a New Brand + +1. **Create brand record** in `brands` table: + ```sql + INSERT INTO brands (id, name, slug, plan_tier, max_users, max_stops_monthly, max_products) + VALUES (gen_random_uuid(), 'Sunrise Farms', 'sunrise-farms', 'starter', 2, 20, 50); + ``` + +2. **Initialize wholesale_settings** (required for portal): + ```sql + INSERT INTO wholesale_settings (brand_id, require_approval, wholesale_enabled) + VALUES ('', true, true); + ``` + +3. **Add-ons are always opt-in** — enable specific features via `brand_features` table or `/admin/settings/apps` UI. + +4. **Create public storefront** — duplicate `/app/tuxedo/` → `/app/sunrise-farms/`, update brand constants and slug. + +5. **Set Stripe customer ID** (for billing portal) — set `stripe_customer_id` column on the brand via Payments settings or API once Stripe is connected. + +### Multi-Tenant Data Model + +- **brands** — each brand has its own products, stops, wholesale settings, communication contacts +- **orders** — belong to a brand (via `brand_id`), may belong to a stop (via `stop_id`) or be shipping-only +- **order_items** — each item has `fulfillment: 'pickup' | 'ship'` +- **stops** — belong to a brand, have address, datetime, status +- **customers** (communication_contacts) — belong to a brand, track email/sms opt-in separately + +### Communications Module ("Harvest Reach") + +The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. + +`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. + +### Payments + +- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds +- **Square** — optional sync for inventory/products; `src/actions/square-*.ts` and `src/app/api/square/` +- Wholesale deposits use a separate `wholesale_deposits` table with `payment_method: 'stripe' | 'square'` + +### Water Log + +Separate from orders/stops — tracks irrigation/water usage per brand. `src/actions/water-log/` and `src/app/admin/water-log/`. Uses its own `water_logs` table. + +--- + +## Key Conventions + +- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues) +- `gen_random_uuid()` used in migrations for primary keys +- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` +- Status enums stored as TEXT — no PostgreSQL ENUM type +- Timestamps use `TIMESTAMPTZ` for timezone awareness +- Address fields on orders table are minimal (`customer_address` TEXT) — full structured address (postal_code, state, country) may not exist; check before assuming FedEx-ready address format +- Migration files: numbered sequentially (e.g., `083_*.sql`), never reuse numbers +- All display dates use `formatDate()` from `src/lib/format-date.ts` (MM/DD/YYYY US locale) — never use raw `toLocaleDateString()` in components + +--- + +## Important File Locations + +| Concern | Location | +|---|---| +| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` | +| Middleware (route protection) | `src/middleware.ts` | +| Server actions | `src/actions/*.ts` (one file per domain) | +| Admin pages | `src/app/admin/[module]/page.tsx` | +| Admin client components | `src/components/admin/*.tsx` | +| Migrations | `supabase/migrations/` | +| Supabase client | `src/lib/supabase.ts` | +| Email templates | `src/lib/email-templates.ts` | +| Date formatting | `src/lib/format-date.ts` | +| Feature flags | `src/lib/feature-flags.ts` | +| Square sync settings UI | `src/app/admin/settings/square-sync/` | +| Wholesale portal | `src/app/wholesale/`, `src/actions/wholesale*.ts` | +| Billing + plan actions | `src/actions/billing/stripe-portal.ts` | +| Add-on settings UI | `src/app/admin/settings/apps/` | +| Billing page | `src/app/admin/settings/billing/` | +| Brand storefront header/footer | `src/components/storefront/StorefrontHeader.tsx`, `src/components/storefront/StorefrontFooter.tsx` | +| Paginated stops (public) | `src/components/storefront/PaginatedStops.tsx` | + +--- + +## Gotchas + +- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone. +- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions. +- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead. +- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item. +- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`. \ No newline at end of file diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md new file mode 100644 index 0000000..8ffdafa --- /dev/null +++ b/ENVIRONMENT.md @@ -0,0 +1,156 @@ +# Environment Setup + +This guide documents every environment variable the platform uses, what each one does, where to get it, and any gotchas. + +Copy `.env.example` → `.env.local` and fill in the values. + +--- + +## Public Variables + +These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`. + +### `NEXT_PUBLIC_SUPABASE_URL` +Your Supabase project URL. +- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL +- **Example:** `https://abc123.supabase.co` + +### `NEXT_PUBLIC_SUPABASE_ANON_KEY` +Supabase anonymous (anon) key — safe for client-side use. +- **Where to get:** Supabase Dashboard → Project Settings → API → anon key + +### `NEXT_PUBLIC_BASE_URL` +The base URL of your deployment. Used for OAuth redirects and webhook URLs. +- **Local:** `http://localhost:3000` +- **Production:** `https://yourdomain.com` + +### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` +Stripe publishable key for Stripe.js in the client. +- **Where to get:** Stripe Dashboard → Developers → API Keys → Publishable key +- **Format:** `pk_live_...` or `pk_test_...` + +### `NEXT_PUBLIC_SINGLE_BRAND` +Set to a brand slug to hide other brand routes in single-brand mode. +- **Example:** `tuxedo` +- **Default:** empty (multi-brand mode) + +--- + +## Server-Only Variables + +**Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes. + +### Supabase + +#### `SUPABASE_SERVICE_ROLE_KEY` +Full service role key — grants DB admin access bypassing RLS. +- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key +- **⚠️ Critical:** Never expose this to the client. Used only in server-side code. + +### Stripe + +#### `STRIPE_SECRET_KEY` +Stripe secret key for backend API calls (creating sessions, webhooks, etc.). +- **Where to get:** Stripe Dashboard → Developers → API Keys → Secret key +- **Format:** `sk_live_...` or `sk_test_...` + +#### `STRIPE_WEBHOOK_SECRET` +Signing secret for Stripe webhook payloads. +- **Where to get:** Stripe Dashboard → Developers → Webhooks → select endpoint → Signing secret +- **Format:** `whsec_...` + +#### Stripe Price IDs + +Create these as recurring prices in your Stripe Dashboard first. Each maps to a plan tier or add-on. + +| Variable | What it is | Stripe Dashboard | +|---|---|---| +| `STRIPE_PRICE_STARTER` | $49/mo Starter plan | Create a Product → Price at $49/mo | +| `STRIPE_PRICE_FARM` | $149/mo Farm plan | Create a Product → Price at $149/mo | +| `STRIPE_PRICE_ENTERPRISE` | $399/mo Enterprise plan | Create a Product → Price at $399/mo | +| `STRIPE_PRICE_HARVEST_REACH` | $79/mo Harvest Reach add-on | Create a Product → Price at $79/mo recurring | +| `STRIPE_PRICE_WHOLESALE_PORTAL` | $99/mo Wholesale Portal add-on | Create a Product → Price at $99/mo recurring | +| `STRIPE_PRICE_WATER_LOG` | $39/mo Water Log add-on | Create a Product → Price at $39/mo recurring | +| `STRIPE_PRICE_AI_TOOLS` | $59/mo AI Intelligence add-on | Create a Product → Price at $59/mo recurring | +| `STRIPE_PRICE_SQUARE_SYNC` | $39/mo Square Sync add-on | Create a Product → Price at $39/mo recurring | +| `STRIPE_PRICE_SMS_CAMPAIGNS` | $29/mo SMS Campaigns add-on | Create a Product → Price at $29/mo recurring | + +**Note:** Annual pricing requires separate annual prices (e.g., $441/yr for Starter = $49 × 12 × 0.75). Use different price IDs and pass `annual=true` to checkout actions. + +### Email (Resend) + +#### `RESEND_API_KEY` +API key for sending transactional email via Resend. +- **Where to get:** [Resend.com](https://resend.com) → API Keys → Create API key +- **Format:** `re_...` + +#### `RESEND_WEBHOOK_SECRET` +Signing secret for Resend webhook payloads. +- **Where to get:** Resend Dashboard → Webhooks → select endpoint → Signing secret +- **Format:** `whsec_...` + +### AI + +#### `OPENAI_API_KEY` +OpenAI API key for AI features (AI Intelligence Pack add-on). +- **Where to get:** [OpenAI Platform](https://platform.openai.com) → API Keys → Create secret key +- **Format:** `sk-...` + +#### `OPENAI_ORG_ID` +OpenAI organization ID (optional — for workspace-level usage tracking). +- **Where to get:** OpenAI Platform → Settings → Organization ID +- **Format:** `org-...` + +### Square + +#### `SQUARE_APP_SECRET` +Square app secret for Square API authentication. +- **Where to get:** Square Developer Dashboard → Your App → Credentials → Application Secret +- **Format:** `sq0...` + +#### `SQUARE_ENVIRONMENT` +Controls whether to use Square sandbox or production. +- **Values:** `sandbox` (default for dev) or `production` +- **⚠️ Note:** Production credentials and sandbox credentials are separate. Make sure this matches which credentials you're using. + +--- + +## Webhook Setup + +### Stripe +**Dashboard:** Stripe Dashboard → Developers → Webhooks → Add endpoint + +- **URL:** `https://yourdomain.com/api/stripe/webhook` +- **Events to listen for:** + - `checkout.session.completed` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `invoice.payment_succeeded` + - `invoice.payment_failed` + +### Resend +**Dashboard:** Resend → Webhooks → Add webhook + +- **URL:** `https://yourdomain.com/api/resend/webhook` +- **Events:** `email_delivered`, `email_bounced`, `email_complained` + +--- + +## Local vs Production + +| Variable | Local (`.env.local`) | Production (hosting dashboard) | +|---|---|---| +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | `https://yourdomain.com` | +| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` | +| `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` | +| `SQUARE_ENVIRONMENT` | `sandbox` | `production` | + +--- + +## Gotchas + +- **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment. +- **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync. +- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type. +- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard. +- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key. \ No newline at end of file diff --git a/PRODUCTION_DEPLOYMENT_CHECKLIST.md b/PRODUCTION_DEPLOYMENT_CHECKLIST.md new file mode 100644 index 0000000..10b5388 --- /dev/null +++ b/PRODUCTION_DEPLOYMENT_CHECKLIST.md @@ -0,0 +1,354 @@ +# Route Commerce — Production Deployment Checklist + +**Platform Version:** 1.6 +**Last Updated:** 2026-05-13 +**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx + +--- + +## 1. Migration Order + +Apply migrations in number order. All numbered `001`–`092` are required. + +```bash +# Push all migrations (sequential) +npm run migrate:one 001 +npm run migrate:one 002 +# ... through ... +npm run migrate:one 092 + +# Or push all at once via Supabase CLI +supabase db push +``` + +**Key migrations (last 15):** + +| # | File | Purpose | +|---|------|---------| +| 078 | `wholesale_deposit_guard.sql` | Adds `amount`, `payment_method` to `wholesale_deposits` | +| 079 | `resend_analytics.sql` | Adds Resend webhook endpoint + analytics tables | +| 080 | `communication_segments.sql` | Adds `communication_segments` table | +| 081 | `stop_blast_campaign.sql` | Adds `stop_blast_campaign` RPC | +| 082 | `brand_name_in_campaign_email.sql` | Adds `brand_name` column to `communication_campaigns` | +| 083 | `shipping_settings.sql` | Adds `shipping_settings` table | +| 084 | `shipments.sql` | Adds `shipments` table for FedEx tracking | +| 085 | `brand_settings.sql` | Adds `brand_settings` table (core) | +| 086 | `brand_settings_email_integration.sql` | Adds email/webhook fields to `brand_settings` | +| 087 | `brand_logos_bucket.sql` | Adds `brand-logos` storage bucket | +| 088 | `brand_features.sql` | Adds `brand_features` table + RPCs for add-on system | +| 089 | `ai_providers_custom_integrations.sql` | Adds AI provider credentials storage | +| 090 | `storefront_settings.sql` | Adds storefront customization fields + `get_brand_settings_by_slug` RPC | +| 091 | `091_brand_plan_tier.sql` | Adds `plan_tier`, limits, `stripe_customer_id`, `get_brand_plan_info` RPC | +| 092 | `092_stripe_subscriptions.sql` | Adds `stripe_subscription_id/status/current_period_end`, `set_brand_subscription`, `get_brand_subscription` RPCs | + +> **Note:** Migration 092 adds Stripe subscription tracking. Migration 091 adds plan tier billing. Apply both before deploying billing changes. + +--- + +## 2. Required Environment Variables + +Copy `.env.local.example` to `.env.local` and fill in all values. + +### Core Platform + +| Variable | Required | Description | +|---|---|---| +| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) | +| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only | +| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI | + +### Payments + +| Variable | Required | Description | +|---|---|---| +| `STRIPE_SECRET_KEY` | Yes (if using Stripe) | Stripe secret key (`sk_live_...` or `sk_test_...`) | +| `STRIPE_WEBHOOK_SECRET` | Yes (if using Stripe webhooks) | Stripe webhook signing secret (`whsec_...`) — from Stripe CLI or dashboard | +| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Yes (if using Stripe) | Stripe publishable key (`pk_live_...` or `pk_test_...`) | + +**Stripe Price IDs** (create in Stripe Dashboard → Products → add Price): + +| Variable | Product | +|---|---| +| `STRIPE_PRICE_STARTER` | Recurring $49/mo — Starter plan | +| `STRIPE_PRICE_FARM` | Recurring $149/mo — Farm plan | +| `STRIPE_PRICE_ENTERPRISE` | Recurring $399/mo — Enterprise plan | +| `STRIPE_PRICE_HARVEST_REACH` | Recurring $79/mo — Harvest Reach add-on | +| `STRIPE_PRICE_WHOLESALE_PORTAL` | Recurring $99/mo — Wholesale Portal add-on | +| `STRIPE_PRICE_WATER_LOG` | Recurring $39/mo — Water Log add-on | +| `STRIPE_PRICE_AI_TOOLS` | Recurring $59/mo — AI Intelligence add-on | +| `STRIPE_PRICE_SQUARE_SYNC` | Recurring $39/mo — Square Sync add-on | +| `STRIPE_PRICE_SMS_CAMPAIGNS` | Recurring $29/mo — SMS Campaigns add-on | + +For annual pricing, create separate annual prices (e.g., $441/yr for Starter = $49×12×0.75) and set `STRIPE_PRICE_STARTER_ANNUAL`, `STRIPE_PRICE_FARM_ANNUAL`, `STRIPE_PRICE_ENTERPRISE_ANNUAL`. + +| Variable | Product | +|---|---| +| `STRIPE_PRICE_STARTER_ANNUAL` | Annual Starter ($441/yr) | +| `STRIPE_PRICE_FARM_ANNUAL` | Annual Farm ($1,341/yr) | +| `STRIPE_PRICE_ENTERPRISE_ANNUAL` | Annual Enterprise ($3,591/yr) | +| `STRIPE_PRICE_HARVEST_REACH_ANNUAL` | Annual Harvest Reach ($711/yr) | +| `STRIPE_PRICE_WHOLESALE_PORTAL_ANNUAL` | Annual Wholesale Portal ($891/yr) | +| `STRIPE_PRICE_WATER_LOG_ANNUAL` | Annual Water Log ($351/yr) | +| `STRIPE_PRICE_AI_TOOLS_ANNUAL` | Annual AI Intelligence ($531/yr) | +| `STRIPE_PRICE_SQUARE_SYNC_ANNUAL` | Annual Square Sync ($351/yr) | +| `STRIPE_PRICE_SMS_CAMPAIGNS_ANNUAL` | Annual SMS Campaigns ($261/yr) | + +| Variable | Required | Description | +|---|---|---| +| `SQUARE_ACCESS_TOKEN` | Yes (if using Square) | Square access token from Square Developer Dashboard | +| `SQUARE_APPLICATION_ID` | Yes (if using Square) | Square application ID | +| `SQUARE_LOCATION_ID` | Yes (if using Square) | Square location ID for inventory sync | +| `SQUARE_WEBHOOK_SIGNATURE_KEY` | Yes (if using Square webhooks) | Square webhook signature key | + +### Email (Resend) + +| Variable | Required | Description | +|---|---|---| +| `RESEND_API_KEY` | Yes (if using email) | Resend API key (`re_...`) | +| `RESEND_WEBHOOK_SECRET` | Yes (if using Resend webhooks) | Resend webhook signing secret | + +### Shipping (FedEx) + +| Variable | Required | Description | +|---|---|---| +| `FEDEX_API_KEY` | Yes (if using FedEx) | FedEx API key from FedEx Developer Portal | +| `FEDEX_API_SECRET` | Yes (if using FedEx) | FedEx API secret | +| `FEDEX_ACCOUNT_NUMBER` | Yes (if using FedEx) | FedEx account number | +| `FEDEX_METER_NUMBER` | Yes (if using FedEx) | FedEx meter number | + +### AI + +| Variable | Required | Description | +|---|---| +| `OPENAI_API_KEY` | Recommended | OpenAI API key (`sk-...`) — default AI provider for AI Intelligence Pack add-on | + +**AI Provider Setup** (`/admin/settings/integrations`): + +Route Commerce supports multiple AI providers via the Integrations settings page: + +| Provider | Auth Method | +|---|---| +| OpenAI | API key (`OPENAI_API_KEY` env var or per-brand settings) | +| Anthropic | API key (set via `/admin/settings/integrations` AI Provider panel) | +| Google Gemini | API key | +| xAI (Grok) | API key | +| Custom / OAI-compatible | API key + base URL | + +All AI tools (Campaign Writer, Pricing Advisor, Demand Forecasting, Route Optimizer, Stop Blast Advisor) respect the provider chosen in `/admin/settings/integrations`. The `getAIClient()` function in `src/actions/integrations/ai-providers.ts` handles provider instantiation dynamically. + +### Feature Flags (Optional) + +| Variable | Required | Description | +|---|---|---| +| `NEXT_PUBLIC_ENABLED_ADDONS` | No | Comma-separated list of add-on keys enabled for all brands (e.g., `harvest_reach,wholesale_portal`). Overrides per-brand `brand_features` table for white-label/self-hosted deployments. | + +--- + +## 3. Webhook Setup + +### Stripe + +1. Go to **Stripe Dashboard → Developers → Webhooks** +2. Add endpoint: `https://yourdomain.com/api/stripe/webhook` +3. Select events: + - `checkout.session.completed` — saves subscription, triggers feature sync + - `customer.subscription.updated` — syncs plan tier + add-on features on changes + - `customer.subscription.deleted` — clears subscription, disables all add-ons + - `invoice.payment_succeeded` — updates next billing date + - `invoice.payment_failed` — marks subscription `past_due`, sends admin notification +4. Copy the **Webhook Secret** (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` + +### Resend + +1. Go to **Resend Dashboard → Webhooks** +2. Add endpoint: `https://yourdomain.com/api/resend/webhook` +3. Select events: `email.delivered`, `email.open`, `email.click`, `email.bounce`, `email.complaint` +4. Copy the webhook secret to `RESEND_WEBHOOK_SECRET` + +### Square (optional) + +1. Go to **Square Developer Dashboard → Webhooks** +2. Add subscription: `https://yourdomain.com/api/square/webhook` +3. Select events: `inventory.count.updated`, `order.created`, `payment.completed` +4. Copy the signature key to `SQUARE_WEBHOOK_SIGNATURE_KEY` + +### FedEx (optional) + +1. FedEx does not use outbound webhooks — track shipments via **FedEx Track API** polling or webhook subscription via FedEx Freight/LTL services + +--- + +## 4. Vercel Cron Jobs + +For scheduled tasks (stop reminders, campaign sends), add to `vercel.json` or configure in **Vercel Dashboard → Project → Cron Jobs**: + +```json +{ + "cron": [ + { + "path": "/api/cron/send-stop-reminders", + "schedule": "0 8 * * *" + }, + { + "path": "/api/cron/process-water-log-reminders", + "schedule": "0 6 * * *" + } + ] +} +``` + +**Note:** Stop reminder emails are triggered by a Supabase time-based trigger (`send_stop_reminder_email`). Verify the trigger is active: +```sql +SELECT * FROM pg_publication_tables WHERE tablename = 'stops'; +``` +If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;` + +--- + +## 5. Feature Flag Enablement (Post-Deploy) + +After deployment, enable add-ons per brand via the admin UI or Supabase SQL: + +```sql +-- Enable Harvest Reach for a brand +SELECT set_brand_feature('your-brand-id', 'harvest_reach', true); + +-- Enable Wholesale Portal +SELECT set_brand_feature('your-brand-id', 'wholesale_portal', true); + +-- Enable Water Log (Tuxedo example) +SELECT set_brand_feature('64294306-5f42-463d-a5e8-2ad6c81a96de', 'water_log', true); + +-- Enable AI Intelligence +SELECT set_brand_feature('your-brand-id', 'ai_tools', true); + +-- Enable Square Sync +SELECT set_brand_feature('your-brand-id', 'square_sync', true); +``` + +Or use the admin UI at **`/admin/settings/apps`** (after logging in as `platform_admin` or `brand_admin` with settings access). + +--- + +## 6. Post-Deploy Verification Checklist + +Run through these after a successful deploy: + +### Core Platform +- [ ] `/admin` loads with correct nav and section cards +- [ ] Can create/edit/archive a stop in `/admin/stops` +- [ ] Can create/edit a product in `/admin/products` +- [ ] Cart works — add item, view cart, checkout +- [ ] Payment completes successfully (test with Stripe test mode) + +### Public Storefronts +- [ ] `/pricing` — public pricing page loads, monthly/annual toggle works, comparison table shows/hides +- [ ] `/tuxedo` loads without platform SiteHeader/SiteFooter (brand header/footer only) +- [ ] `/indian-river-direct` loads without platform SiteHeader/SiteFooter +- [ ] "Admin" link visible in brand footer when logged in as admin +- [ ] Stops display: tabbed view works, "This Week" default, "Full Season" pagination works +- [ ] Schedule PDF download works (`/api/tuxedo/schedule-pdf`, `/api/indian-river-direct/schedule-pdf`) +- [ ] Wholesale portal banner shows/hides based on `wholesale_enabled` setting +- [ ] Zip code search works on IRD homepage + +### Add-ons +- [ ] `/admin/communications` loads (Harvest Reach — requires enablement) +- [ ] `/admin/wholesale` loads (Wholesale Portal — requires enablement) +- [ ] `/admin/settings/ai` loads (AI Tools — requires enablement + API key) +- [ ] `/admin/settings/square-sync` loads (Square Sync — requires enablement + Square credentials) +- [ ] `/admin/water-log` loads for authorized brand (Water Log — requires enablement) + +### Admin Settings +- [ ] `/admin/settings` — all sections visible including "Add-ons" +- [ ] `/admin/settings/brand` — can save hero tagline, about headline, feature toggles +- [ ] `/admin/settings/apps` — can enable/disable add-on features +- [ ] `/admin/settings/billing` — shows plan tier, subscription status badge, next billing date, Stripe portal button +- [ ] `/admin/settings/integrations` — AI provider configuration (OpenAI/Anthropic/Google/xAI/custom) +- [ ] After toggling an add-on: dashboard card updates badge from "Add-on" to "Enabled" + +### Billing +- [ ] Stripe webhook receives `checkout.session.completed` and saves subscription +- [ ] `/admin/settings/billing` shows correct plan tier and add-on status +- [ ] Subscription status badge shows `active` / `past_due` / `canceled` +- [ ] Next billing date displays correctly (from `stripe_current_period_end`) +- [ ] "Manage in Stripe Portal" button opens Stripe Customer Portal +- [ ] Stripe Customer Portal shows correct plan + add-ons +- [ ] Invoice history table shows mock invoice rows (ready for real Stripe data) +- [ ] Monthly/annual toggle recalculates displayed prices on billing page +- [ ] Add-on enable/disable triggers Stripe checkout / cancellation + +### Auth & Permissions +- [ ] Login with platform admin — full access to all brands +- [ ] Login with brand admin — access limited to assigned brand +- [ ] Login with store employee — restricted to pickup and wholesale only +- [ ] `dev_session=platform_admin` cookie works for local development bypass + +### Reports +- [ ] `/admin/reports` — all tabs load (Overview, Orders by Stop, Sales by Product, Fulfillment, Pickup Status, Contact Growth, Campaigns) +- [ ] Date range selector works +- [ ] CSV export works for each report + +--- + +## 7. Rollback Plan + +### Immediate Rollback (Vercel) +```bash +# Rollback to previous deployment in Vercel Dashboard +# Project → Deployments → find last good deployment → "Promote to Production" +``` +Vercel keeps 10 recent deployments. For critical regressions, promote the previous deployment. + +### Database Rollback +```bash +# Revert last migration via Supabase +supabase db reset --backup-id + +# Or manually revert (example for migration 090) +psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;" +``` +> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss. + +### Environment Variable Rollback +Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**. + +--- + +## 8. Known Limitations & Future Work + +### Known Issues +- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`. +- **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable. +- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries. +- **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match. +- **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square. +- **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns. +- **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions. +- **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production. +- **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication. +- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code. + +### Future Work (Non-Blocking) +- **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts) +- **Webhook retry queue**: Failed webhooks (Resend, Stripe) are not currently retried — implement a dead-letter queue or retry mechanism +- **Import Center**: AI-powered import UI (`/admin/import`) was designed but not fully implemented +- **Multi-brand support in public storefront**: Currently hardcoded for Tuxedo and IRD — a generic `/[brandSlug]` route would support all brands +- **AI features in reports**: `ReportsDashboard` has an "Explain" button for AI-powered explanations but it requires OpenAI key + working API proxy +- **Square inventory mapping**: `syncInventoryToSquare` and `syncInventoryFromSquare` require a `square_catalog_object_id` column on products to map RC products to Square catalog items — not yet implemented +- **Webhook event deduplication**: Add a `stripe_events_processed` table or similar to track processed Stripe event IDs and prevent duplicate processing + +--- + +## 9. Contacts & On-Call + +| Role | Contact | +|------|---------| +| Platform Admin | Set via Supabase Auth dashboard | +| Kyle Martinez | Primary contact — Route Commerce owner | +| Supabase Support | supabase.com/support | +| Vercel Support | vercel.com/support | + +For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button. + +For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f085eb6 --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +# Route Commerce + +A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard. + +## What It Does + +Route Commerce helps produce brands run their wholesale operations: + +- **Product catalogs** — Manage products with pricing, variants, and images +- **Stop/route scheduling** — Schedule pickup stops across regions with customer tracking +- **Order management** — Handle pickup and shipping orders, track fulfillment +- **Wholesale portal** — Self-service portal for wholesale customers to place orders +- **Billing** — Stripe-based subscription billing with plan tiers and add-ons +- **Communications** — Email/SMS campaigns via Harvest Reach module +- **Square sync** — Optional inventory sync with Square POS + +## Tech Stack + +- **Framework:** Next.js 16 (App Router) +- **Database:** Supabase (Postgres + Auth + RLS) +- **Payments:** Stripe + Square +- **Email/SMS:** Resend +- **Styling:** Tailwind CSS v4 + +## Getting Started + +### 1. Clone and install + +```bash +git clone +cd route-commerce-platform +npm install +``` + +### 2. Set up environment variables + +Create a `.env.local` file with: + +```bash +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# Stripe +STRIPE_SECRET_KEY=sk_test_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRICE_STARTER=price_... +STRIPE_PRICE_FARM=price_... +STRIPE_PRICE_ENTERPRISE=price_... + +# Resend (email) +RESEND_API_KEY=re_... +``` + +### 3. Set up the database + +Link your Supabase project, then push migrations: + +```bash +supabase link --project-ref +npm run migrate +``` + +Or push a single migration: + +```bash +npm run migrate:one 83 +``` + +### 4. Start the dev server + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. + +### 5. Dev auth bypass + +In development, `/login` sets a `dev_session` cookie for full platform access: + +- `dev_session=platform_admin` — full access, all brands +- `dev_session=brand_admin` — brand-scoped access only + +Visit `/login` as a shortcut to set the platform_admin session. + +## Key Commands + +```bash +npm run dev # Start dev server +npm run build # Production build +npm run lint # ESLint +npm run migrate # Push all pending migrations +npm run migrate:one 83 # Push migration 083_*.sql only +npx tsc --noEmit # TypeScript check (no emit) +npx playwright test # Run E2E tests +``` + +## Project Structure + +``` +src/ +├── actions/ # Server actions (database writes) +├── app/ +│ ├── admin/ # Admin dashboard pages +│ ├── api/ # API routes (Stripe, Resend, Square webhooks) +│ └── storefront/ # Public brand storefront pages +├── components/ # Shared React components +│ ├── admin/ # Admin-specific components +│ └── storefront/ # Storefront components +└── lib/ # Utilities, auth, feature flags, formatting +supabase/ +└── migrations/ # SQL migrations (numbered sequentially) +``` + +## Plan Tiers + +| Tier | Price | Includes | +|---|---|---| +| **Starter** | $49/mo | Products, 10 stops/mo, Orders, Basic Pickup, 1 user, 25 products | +| **Farm** | $149/mo | Everything in Starter + Wholesale Portal, Harvest Reach, unlimited stops/products, 5 users | +| **Enterprise** | Custom | Everything in Farm + AI Intelligence Pack, SMS Campaigns, Square Sync, Water Log, unlimited users | + +**Add-ons (à la carte on any plan):** + +- Wholesale Portal ($99/mo) +- Harvest Reach email/SMS ($79/mo) +- AI Intelligence Pack ($59/mo) +- Water Log ($39/mo) +- Square Sync ($39/mo) +- SMS Campaigns ($29/mo) + +## Time Tracking + +### Adding Workers +1. Go to `/admin/settings` → expand **Workers & PINs** +2. Click **+ Add Worker** — enter name, role (Worker or Time Admin), language (EN/ES) +3. PINs are auto-generated. Click **Reset PIN** next to any worker to issue a new one + +### Adding Tasks +1. Go to `/admin/settings` → expand **Tasks** +2. Click **+ Add Task** — enter EN name, optional ES name, unit (hours/pieces/units), sort order +3. Tasks appear in the time clock app for workers to clock into + +### Notification Alerts +1. Go to `/admin/settings` → expand **General Settings** +2. Add email addresses and/or SMS numbers under **Notification Recipients** +3. Configure daily/weekly overtime alert thresholds + +## Admin Settings Page + +All brand configuration lives at `/admin/settings` with five sections: + +- **General** — Pay period, overtime thresholds, alert settings, notification recipients +- **Workers & PINs** — Manage workers, reset PINs, toggle active/inactive +- **Tasks** — Define tasks workers can clock into +- **Users & Permissions** — Manage admin users and role flags +- **Integrations** — Stripe, Resend, Twilio, OpenAI credentials and sync options + +## Admin Modules + +The admin dashboard lives at `/admin`: + +- **Command Center** — Overview dashboard +- **Orders** — All orders with pickup/ship status +- **Stops** — Route/stop scheduling and management +- **Products** — Product catalog management +- **Customers** — Wholesale customer management +- **Communications** — Harvest Reach campaign manager +- **Wholesale** — Wholesale portal settings +- **Billing** — Plan and subscription management +- **Water Log** — Irrigation tracking (add-on) +- **Settings** — Brand settings, payments, apps + +## Email Automations (Harvest Reach) + +Automated email sequences for wholesale customer engagement. + +### Abandoned Cart Recovery + +3-email sequence (1hr → 24hr → 48hr) triggered when a wholesale customer adds items to cart but doesn't checkout. + +**Cron schedule:** Every 6 hours via Vercel crons (`vercel.json`) + +**Dashboard:** `/admin/communications/abandoned-carts` + +**How it works:** +1. `detect_abandoned_wholesale_carts` RPC finds pending wholesale orders not yet enrolled +2. `enroll_abandoned_cart` RPC creates a tracking record with first email scheduled in 1 hour +3. Cron job calls `get_active_abandoned_carts` to find carts where `next_email_at <= now()` +4. `sendAbandonedCartEmail` fires the email via Resend, then updates `sequence_step`, `next_email_at`, and `status` + +**Admin actions:** +- **View** — see all carts with status (Active/Recovered/Expired/Manually Closed), step, items, total +- **Close** — marks cart as manually_closed (stops further emails) +- **Resend** — resets `next_email_at` to now() and fires email immediately + +### Welcome Email Sequence + +4-email onboarding series triggered when a wholesale contact subscribes (email_opt_in = TRUE). + +**Cron schedule:** Every 6 hours (same as abandoned cart) + +**Dashboard:** `/admin/communications/welcome-sequence` + +**How it works:** +1. Contact subscribes via wholesale portal → `enroll_welcome_sequence` RPC is called +2. Cron job calls `get_active_welcome_sequence` for entries where `next_email_at <= now()` +3. `sendWelcomeEmail` fires via Resend, updates step, schedules next email 24h later +4. After step 4, status → `completed` + +**Admin actions:** +- **Resend** — resets entry to step 1 and fires immediately +- Unsubscribed/bounced contacts are skipped automatically + +### Manual Trigger (curl) + +```bash +# Abandoned cart — simulate cron +curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/abandoned-cart \ + -H "Authorization: Bearer $CRON_SECRET" \ + -H "Content-Type: application/json" + +# Welcome sequence +curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/welcome-sequence \ + -H "Authorization: Bearer $CRON_SECRET" \ + -H "Content-Type: application/json" + +# Check status / health +curl https://route-commerce-platform.vercel.app/api/email-automation/abandoned-cart +``` + +### Environment Variables Required + +```bash +RESEND_API_KEY=re_... # Resend API key for sending emails +FROM_EMAIL="Tuxedo Corn " # Sender address +CRON_SECRET=... # Bearer token to secure cron endpoints (generate a long random string) +``` + +### Troubleshooting + +| Problem | Likely cause | Fix | +|---|---|---| +| Emails not sending | `RESEND_API_KEY` not set | Check env var in Vercel dashboard | +| 401 Unauthorized on cron | `CRON_SECRET` mismatch | Verify local `.env.local` matches Vercel env | +| Empty abandoned cart table | No pending wholesale orders | Create test order, add items, abandon checkout | +| Welcome sequence not enrolling contacts | `enroll_welcome_sequence` not called | Ensure subscription UI calls the RPC on email_opt_in = TRUE | + +### Testing Locally + +```bash +# Run abandoned cart with verbose output +curl -X POST http://localhost:3000/api/email-automation/abandoned-cart \ + -H "Authorization: Bearer local-dev-secret" \ + -H "Content-Type: application/json" + +# Response: { ok: true, results: { "Tuxedo Corn": { sent: 0, failed: 0, skipped: 0 }, ... } } +``` + +Set `CRON_SECRET=local-dev-secret` in `.env.local` for local testing. + +## Go-Live Checklist + +Use this checklist before adding real products and doing a soft launch. + +### Pre-Launch + +- [ ] Set `CRON_SECRET` in Vercel project environment variables (generate with `openssl rand -hex 32`) +- [ ] Verify `RESEND_API_KEY` is set in Vercel (for email sending) +- [ ] Verify `FROM_EMAIL` is set (e.g. `"Tuxedo Corn "`) +- [ ] Run `npm run build` locally — must pass clean with zero TypeScript errors +- [ ] Deploy latest commit to production: `vercel --prod` +- [ ] Smoke test: visit `/admin`, `/admin/settings`, `/tuxedo` — no console errors + +### Adding Your First Brand's Products + +1. Go to `/admin/products` → **Add Product** +2. For each product: name, wholesale price, image, shipping type (pickup/ship/all) +3. Set `is_taxable` if Colorado sales tax applies +4. Make products **active** — inactive products don't show on storefront +5. For Tuxedo Corn: use brand slug `tuxedo`, set brand accent `green` + +### Adding Pickup Stops + +1. Go to `/admin/stops` → **Create Stop** +2. Fill city, state, address, date, time, cutoff date +3. Assign brand — stops show on storefront at `/{brand-slug}#stops` +4. Set `is_public: true` to display on storefront + +### Verifying Wholesale Portal + +1. Go to `/admin/wholesale` — confirm portal is enabled +2. Visit `/{brand-slug}/wholesale/portal` as a customer +3. Add items to cart, start checkout — confirm abandoned cart appears in `/admin/communications/abandoned-carts` within minutes + +### Verifying Email Automations + +```bash +# Manually trigger welcome sequence (use a real email you control) +curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/welcome-sequence \ + -H "Authorization: Bearer $CRON_SECRET" + +# Check Resend dashboard — should see sent emails +# Check /admin/communications/welcome-sequence — entry should show "Active" +``` + +### Monitoring Production + +- Check Vercel dashboard → **Functions** logs for any 500 errors +- Check `/admin/communications/abandoned-carts` for recovered orders +- Vercel crons fire every 6 hours — check function invocations in dashboard + +## Notes + +- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly +- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production +- Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs +- Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()` +- Migration files are numbered sequentially — never reuse numbers \ No newline at end of file diff --git a/debug-auth-test.ts b/debug-auth-test.ts new file mode 100644 index 0000000..981e6db --- /dev/null +++ b/debug-auth-test.ts @@ -0,0 +1,53 @@ +import { chromium } from 'playwright'; + +async function debugAuth() { + console.log('Launching browser...'); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + + // First, login + console.log('Navigating to login page...'); + await page.goto('https://route-commerce-platform.vercel.app/login'); + + console.log('Filling login form...'); + await page.fill('#email', 'kylemart@gmail.com'); + await page.fill('#password', 'Test123456!'); + + console.log('Clicking sign in...'); + const response = await page.click('button[type="submit"]'); + + // Wait for network to settle + await page.waitForLoadState('networkidle').catch(() => {}); + + console.log('Current URL after wait:', page.url()); + + // Get any error messages + const errorText = await page.$eval('[role="alert"]', el => el.textContent).catch(() => null); + if (errorText) console.log('Error message:', errorText); + + const pageContent = await page.content(); + if (pageContent.includes('Access Denied')) { + console.log('*** ACCESS DENIED PAGE DETECTED ***'); + } + + // Check cookies + const cookies = await context.cookies(); + console.log('Cookies:', cookies.map(c => `${c.name}=${c.value.slice(0, 30)}...`)); + + // Try to visit debug-auth page + console.log('Navigating to /admin/debug-auth...'); + try { + const response = await page.goto('https://route-commerce-platform.vercel.app/admin/debug-auth', { timeout: 10000 }); + console.log('Response status:', response?.status()); + console.log('Response URL:', page.url()); + const content = await page.content(); + console.log('Page content (first 2000 chars):', content.slice(0, 2000)); + } catch (e) { + console.log('Error:', e); + } + + await browser.close(); +} + +debugAuth().catch(console.error); \ No newline at end of file diff --git a/docs/superpowers/plans/2025-01-14-admin-redesign-plan.md b/docs/superpowers/plans/2025-01-14-admin-redesign-plan.md new file mode 100644 index 0000000..9eb0ca1 --- /dev/null +++ b/docs/superpowers/plans/2025-01-14-admin-redesign-plan.md @@ -0,0 +1,399 @@ +# Admin Pages Redesign Implementation Plan + +**Goal:** Unify admin pages with warm earth-tone dark sidebar, cream content, emerald accent, monospace data tables. + +**Architecture:** Create shared CSS design tokens, update sidebar with warm dark theme, then update each content page to use consistent styling. + +**Tech Stack:** Next.js, Tailwind CSS, React + +--- + +## Task 1: Create CSS Design Tokens + +**Files:** +- Create: `src/styles/admin-tokens.css` + +- [ ] **Step 1: Create CSS tokens file** + +```css +/* Admin Design Tokens */ +:root { + /* Sidebar - Warm Earth Tones */ + --sidebar-bg: #1c1917; + --sidebar-border: #292524; + --sidebar-text: #d6d3d1; + --sidebar-text-muted: #78716c; + --sidebar-text-hover: #fafaf9; + --sidebar-active-bg: rgba(5, 150, 105, 0.1); + --sidebar-active-border: #059669; + + /* Content Area - Warm Cream */ + --bg-page: #fdfaf6; + --bg-card: #ffffff; + --border-subtle: #e7e5e4; + --text-primary: #1c1917; + --text-secondary: #57534e; + --text-muted: #a8a29e; + + /* Accent - Emerald */ + --accent: #059669; + --accent-hover: #047857; + --accent-light: #d1fae5; + --accent-text: #065f46; + + /* Status */ + --status-pending: #f59e0b; + --status-active: #059669; + --status-muted: #a8a29e; + + /* Shadows */ + --shadow-card: 0 1px 3px rgba(28, 25, 23, 0.06), 0 1px 2px rgba(28, 25, 23, 0.04); + --shadow-card-hover: 0 4px 6px rgba(28, 25, 23, 0.08), 0 2px 4px rgba(28, 25, 23, 0.06); + + /* Typography */ + --font-heading: 'DM Sans', system-ui, sans-serif; + --font-body: 'DM Sans', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + + /* Spacing */ + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + + /* Border Radius */ + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; +} +``` + +- [ ] **Step 2: Add to global CSS or import in layout** + +Check `src/app/globals.css` and add import at top: +```css +@import './styles/admin-tokens.css'; +``` + +Run: `ls src/app/globals.css` +Expected: File exists + +- [ ] **Step 3: Commit** + +```bash +git add src/styles/admin-tokens.css src/app/globals.css +git commit -m "feat: add admin CSS design tokens" +``` + +--- + +## Task 2: Update AdminSidebar Component + +**Files:** +- Modify: `src/components/admin/AdminSidebar.tsx` + +- [ ] **Step 1: Read current sidebar** + +Run: `cat src/components/admin/AdminSidebar.tsx | head -120` + +- [ ] **Step 2: Update sidebar styling for warm earth tones** + +Replace the sidebar className from: +```css +bg-white border-r border-stone-200 +``` +To: +```css +bg-stone-900 border-r border-stone-800 +``` + +Update text colors from stone-600/stone-950 to warm equivalents: +- inactive text: stone-300 → sidebar-text +- active text: emerald-700 → emerald-400 +- hover: stone-50 → sidebar-text-hover + +Update active state background from `bg-emerald-50` to `bg-emerald-900/20` + +Update role badge background from `bg-stone-50` to `bg-stone-800` + +Update sign out button hover from `bg-stone-50` to `bg-stone-800` + +- [ ] **Step 3: Add Back to Site link** + +In the logo row, after the admin logo link, add: +```tsx + + ← Back to Site + +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/components/admin/AdminSidebar.tsx +git commit -m "style: update sidebar to warm earth tones with Back to Site link" +``` + +--- + +## Task 3: Update Admin Layout Background + +**Files:** +- Modify: `src/app/admin/layout.tsx` + +- [ ] **Step 1: Update background class** + +Change from: +```tsx +
+``` +To: +```tsx +
+``` + +- [ ] **Step 2: Also update the access denied div** + +Same change for the access denied div. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/admin/layout.tsx +git commit -m "style: update admin layout to use warm cream background" +``` + +--- + +## Task 4: Update Dashboard Page + +**Files:** +- Modify: `src/app/admin/page.tsx` + +- [ ] **Step 1: Update page background** + +Change `bg-stone-100` to `bg-transparent` (inherits from layout) + +- [ ] **Step 2: Update section headers** + +Change `text-stone-500` (muted labels) to `var(--text-muted)` +Change `text-stone-950` to `var(--text-primary)` +Change `text-stone-600` to `var(--text-secondary)` + +- [ ] **Step 3: Update cards to use white bg** + +Cards already use `card` class which should be white with shadow. Verify this works with the warm cream background. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/admin/page.tsx +git commit -m "style: update dashboard page styling" +``` + +--- + +## Task 5: Update Orders Page + +**Files:** +- Modify: `src/app/admin/orders/page.tsx` +- Modify: `src/components/admin/AdminOrdersPanel.tsx` + +- [ ] **Step 1: Remove dark wrapper from orders panel** + +In AdminOrdersPanel, remove the dark wrapper: +```tsx +// Remove:
+// Just return the inner content, let layout handle bg +``` + +- [ ] **Step 2: Update table to warm cream theme** + +Replace all `bg-zinc-900`, `bg-zinc-950`, `border-zinc-800`, `text-zinc-*` with warm equivalents: +- `bg-zinc-900` → `bg-white` +- `bg-zinc-950` → `bg-stone-50` +- `border-zinc-800` → `border-stone-200` +- `text-zinc-100` → `text-stone-900` +- `text-zinc-200` → `text-stone-700` +- `text-zinc-400` → `text-stone-500` +- `text-zinc-500` → `text-stone-400` + +- [ ] **Step 3: Add monospace to data cells** + +Add `font-mono` to order ID, phone numbers, dates, amounts. + +- [ ] **Step 4: Update status badges** + +Replace `bg-emerald-950/60 text-emerald-400 border-emerald-800/50` with: +```tsx +bg-emerald-100 text-emerald-700 border border-emerald-200 +``` + +Same for amber and purple status badges. + +- [ ] **Step 5: Update filter bar** + +Change from dark filter buttons to light theme: +```tsx +
+ + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify build** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/app/admin/test-auth/page.tsx +git commit -m "feat: add /admin/test-auth debug page for auth troubleshooting + +Shows all cookie values, rc_auth_uid status, rc_access_token status, +and getAdminUser() result. Helps diagnose auth issues quickly in prod." +``` + +--- + +## Task 6: Deploy and Verify + +- [ ] **Step 1: Push all commits** + +```bash +git push +``` + +- [ ] **Step 2: Deploy to Vercel production** + +```bash +npx vercel --prod +``` + +Wait for deployment to be READY. + +- [ ] **Step 3: Verification checklist** + +In an incognito browser window: +1. Go to `/admin` — should redirect to `/login` +2. Clear all cookies +3. Go to `/login` — should show login form with no errors +4. Log in with real credentials +5. Should land on `/admin` — no "Auth Error" or "Access Denied" +6. Go to `/admin/test-auth` — should show: + - `rc_auth_uid` = SET + - `rc_access_token` = NOT SET + - `getAdminUser()` = AUTHENTICATED +7. Logout — should clear `rc_auth_uid` +8. Try accessing `/admin` again — should redirect to `/login` + +--- + +## Verification Plan + +After each fix, clear all cookies and test login flow end-to-end: + +1. **Clear cookies** in browser dev tools (Application tab → Cookies → Delete all) +2. **Login** at `/login` with known-valid credentials +3. **Land on `/admin`** — should show admin dashboard, not an error +4. **Check `/admin/test-auth`** — `rc_auth_uid` SET, `rc_access_token` NOT SET, AUTHENTICATED +5. **Logout** and verify redirect to `/login` + +If any step fails, stop and diagnose before proceeding to next task. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-23-login-production-failure.md b/docs/superpowers/plans/2026-05-23-login-production-failure.md new file mode 100644 index 0000000..ca1b4b9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-login-production-failure.md @@ -0,0 +1,270 @@ +# Login Page Production Failure — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Diagnose why the login page works in development but fails in production, then implement a fix. + +**Architecture:** The login flow involves three layers: (1) the login page form posting to `/api/login`, (2) the API route validating credentials and setting auth cookies, (3) middleware reading cookies to restore sessions. Production failures typically stem from cookie security settings, environment variable mismatches, or proxy/hosting platform differences. + +**Tech Stack:** Next.js 16 App Router · Supabase Auth · Vercel (production) · Node.js + +--- + +## File Structure + +- **`src/app/login/page.tsx`** — Login form UI, posts to `/api/login` +- **`src/app/api/login/route.ts`** — Validates credentials, creates admin_users, sets `rc_access_token` and `rc_uid` cookies +- **`src/middleware.ts`** — Reads auth cookies (`rc_auth_uid`, `rc_auth_token`, `dev_session`) to restore session +- **`src/lib/admin-permissions.ts`** — `getAdminUser()` resolves auth from cookies, dev bypass, proxy UID, Supabase token +- **`src/lib/admin-permissions-service.ts`** — Creates admin_users via Supabase REST API (apikey-only, no Authorization header) + +--- + +## Diagnostic Tasks + +### Task 1: Reproduce and Instrument the Production Failure + +**Files:** +- Modify: `src/app/api/login/route.ts:1-120` +- Modify: `src/middleware.ts:1-100` + +- [ ] **Step 1: Add diagnostic logging to /api/login route** + +Open `src/app/api/login/route.ts` and add console logging at each failure point. The route currently handles three failure modes: (1) credentials rejected by Supabase, (2) admin_users lookup fails, (3) cookie setting fails. + +```typescript +// Add at the top of the POST handler, after extracting formData +console.log("[/api/login] isProd:", isProd, "NODE_ENV:", process.env.NODE_ENV); +console.log("[/api/login] Email:", email, "Password length:", password?.length); +console.log("[/api/login] Supabase URL:", supabaseUrl ? "set" : "MISSING"); +console.log("[/api/login] Anon key:", supabaseAnonKey ? "set" : "MISSING"); +console.log("[/api/login] Service role:", serviceRoleKey ? "set" : "MISSING"); +``` + +- [ ] **Step 2: Add diagnostic logging to middleware auth resolution** + +Open `src/middleware.ts` and log each auth resolution path at the start of the middleware function: + +```typescript +console.log("[middleware] pathname:", request.nextUrl.pathname); +console.log("[middleware] dev_session:", devSession); +console.log("[middleware] rc_auth_uid:", rcAuthUid); +console.log("[middleware] rc_auth_token:", rcAuthToken ? "present" : "MISSING"); +console.log("[middleware] rc_access_token:", rcAccessToken ? "present" : "MISSING"); +console.log("[middleware] isProd:", process.env.NODE_ENV === "production"); +``` + +- [ ] **Step 3: Deploy and reproduce** + +Run `npm run build && npm run start` locally in production mode (or deploy to Vercel) and attempt login. Capture all console output from both the API route and middleware. + +Expected: You will see which path fails — either the Supabase signInWithPassword call, the admin_users creation, or the cookie setting. + +--- + +### Task 2: Check Cookie Configuration Differences + +**Files:** +- Review: `src/app/api/login/route.ts:90-110` (cookie options) +- Review: `src/middleware.ts:23-54` (cookie reading) +- Review: `src/app/api/water-admin-auth/route.ts` (another cookie example) + +- [ ] **Step 1: Document cookie options in each file** + +The `/api/login` route sets cookies with these options (lines 99-105): +```typescript +const cookieOpts = { + path: "/", + maxAge: 60 * 60 * 24 * 30, + httpOnly: true, + sameSite: "lax", + ...(isProd ? { secure: true } : {}), // secure:true only in production +}; +``` + +In production (HTTPS), `secure: true` is required for cookies to work. In development (HTTP), `secure: false` is needed. The conditional `...(isProd ? { secure: true } : {})` handles this. + +- [ ] **Step 2: Verify the `isProd` variable is correctly computed** + +Find where `isProd` is defined in `src/app/api/login/route.ts`. It should be: +```typescript +const isProd = process.env.NODE_ENV === "production"; +``` + +If `NODE_ENV` is set differently in production (e.g., not explicitly set, or set to "production" but the Next.js server runs differently on Vercel), this could cause the cookie to be set without `secure: true` in production, making the cookie get rejected by the browser. + +- [ ] **Step 3: Check if `SameSite=lax` causes issues on Vercel** + +On some Vercel configurations, `SameSite=lax` combined with `secure: true` can cause issues if the domain has redirects or if there's a proxy in front. Try changing to `sameSite: "none"` with `secure: true` in production (requires HTTPS), or investigate if the cookie domain needs to be explicitly set. + +--- + +### Task 3: Verify Environment Variables in Production + +**Files:** +- Review: `src/app/api/login/route.ts:20-35` +- Review: `.env.example` +- Review: `.env.local` (development values) + +- [ ] **Step 1: List all env vars used in the login flow** + +The login route uses: +```typescript +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; +``` + +- [ ] **Step 2: Check Vercel environment variables** + +In Vercel dashboard → Settings → Environment Variables, verify: +1. `NEXT_PUBLIC_SUPABASE_URL` is set (not `NEXT_PUBLIC_` prefix in some cases) +2. `NEXT_PUBLIC_SUPABASE_ANON_KEY` is set +3. `SUPABASE_SERVICE_ROLE_KEY` is set (this MUST be a server-side only variable, never exposed to client) + +**Common issue:** `SUPABASE_SERVICE_ROLE_KEY` is set but the Next.js server component can't read it because Vercel's edge runtime doesn't have access to server-side env vars the same way. + +- [ ] **Step 3: Verify Supabase URL format** + +The Supabase URL must be the project URL (e.g., `https://xxxxx.supabase.co`), not the internal postgres URL. Check that the production env var matches the development value. + +--- + +### Task 4: Check Middleware Auth Resolution + +**Files:** +- Modify: `src/middleware.ts:23-54` + +- [ ] **Step 1: Instrument the middleware auth resolution path** + +Add logging to each branch of the auth resolution: +```typescript +if (isDevMode) { + console.log("[middleware] Using dev mode, DEV_UID:", DEV_UID); + authUid = DEV_UID; +} else if (rcAuthUid === DEV_FORCE_UID) { + console.log("[middleware] Using dev force UID"); + authUid = DEV_FORCE_UID; +} else if (rcAuthUid) { + console.log("[middleware] Using rc_auth_uid:", rcAuthUid); + authUid = rcAuthUid; +} else if (rcAccessToken) { + console.log("[middleware] Attempting rc_access_token validation"); + // JWT decode and validate... +} +``` + +- [ ] **Step 2: Check the `trust proxy` setting** + +If the application runs behind a Vercel proxy or CDN, the `x-forwarded-proto` header may need to be trusted. In Next.js, add to `next.config.ts`: +```typescript +async headers() { + return [{ + source: '/(.*)', + headers: [{ key: 'X-Forwarded-Proto', value: 'https' }], + }]; +}, +``` + +Or in `middleware.ts`, check `request.headers.get('x-forwarded-proto')`. + +--- + +### Task 5: Check Supabase Auth Configuration + +**Files:** +- Review: Supabase Dashboard → Authentication → Settings +- Review: `src/lib/supabase.ts` + +- [ ] **Step 1: Verify Supabase site URL in dashboard** + +In Supabase Dashboard → Authentication → Settings → Site URL, verify the production URL matches: +- Dev: `http://localhost:3000` +- Prod: `https://your-production-domain.com` + +If the Site URL doesn't match, Supabase may reject the auth redirect. + +- [ ] **Step 2: Check redirect URLs in Supabase** + +Supabase → Authentication → URL Configuration → Redirect URLs should include: +- `https://your-production-domain.com/login` +- `https://your-production-domain.com/admin/*` + +Any redirect URL mismatch causes silent failures. + +- [ ] **Step 3: Check Supabase anon key permissions** + +The anon key is used client-side but also in server components. Verify the anon key hasn't been rotated or the project hasn't been migrated to a new Supabase instance. + +--- + +### Task 6: Implement Fixes Based on Diagnosis + +**Files:** +- Modify: `src/app/api/login/route.ts` +- Modify: `src/middleware.ts` + +- [ ] **Step 1: If cookie secure flag is the issue** + +Update cookie options to explicitly handle all cases: +```typescript +const isProd = process.env.NODE_ENV === "production"; +const cookieOpts = { + path: "/", + maxAge: 60 * 60 * 24 * 30, + httpOnly: true, + sameSite: isProd ? "none" : "lax", // none requires secure + secure: isProd, // true in production (HTTPS), false in dev +}; +``` + +- [ ] **Step 2: If env vars are missing in Vercel edge runtime** + +Move critical env var reading to a function that handles missing vars gracefully: +```typescript +function getRequiredEnv(key: string): string { + const value = process.env[key]; + if (!value) { + console.error(`[login] Missing required env var: ${key}`); + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} +``` + +- [ ] **Step 3: If middleware cookie reading is failing** + +The middleware runs on the Vercel Edge Runtime which has limited cookie access. Ensure `rc_auth_uid` and `rc_auth_token` cookies are set with appropriate domain and path: +```typescript +cookieStore.set("rc_auth_uid", uid, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + // No domain specified — uses current hostname +}); +``` + +--- + +## Verification Plan + +After each fix: + +1. **Clear all cookies** in browser dev tools (Application tab → Cookies → Delete all) +2. **Restart the production server** or redeploy to Vercel +3. **Attempt login** with a known-valid account +4. **Check browser dev tools** → Network tab → `/api/login` request + - Response status should be 200 or 3xx (redirect), not 400/401/500 +5. **Check Application tab** → Cookies for `rc_access_token` and `rc_uid` +6. **Check console logs** in both the browser and server for errors + +--- + +## Execution OPTIONS + +**Option A (Recommended):** Use `superpowers:subagent-driven-development` — dispatch a subagent to work through each diagnostic task with two-stage review. + +**Option B:** Use `superpowers:systematic-debugging` first to pinpoint the exact failure point, then implement targeted fixes. + +**Which approach?** \ No newline at end of file diff --git a/docs/superpowers/specs/2025-01-14-admin-redesign-design.md b/docs/superpowers/specs/2025-01-14-admin-redesign-design.md new file mode 100644 index 0000000..98fcd49 --- /dev/null +++ b/docs/superpowers/specs/2025-01-14-admin-redesign-design.md @@ -0,0 +1,172 @@ +# Admin Pages Redesign — Design Spec + +**Date:** 2025-01-14 +**Status:** Approved + +## Overview + +Unify the admin pages with a consistent design system: warm earth-tone dark sidebar, cream/ivory content area, emerald accent, monospace for data. Eliminate duplicate header/sidebar navigation — sidebar is the single nav surface. + +## Design Tokens + +### Colors + +```css +/* Sidebar */ +--sidebar-bg: #1c1917; /* stone-900, warm dark */ +--sidebar-border: #292524; /* stone-800 */ +--sidebar-text: #d6d3d1; /* stone-300 */ +--sidebar-text-muted: #78716c; /* stone-500 */ +--sidebar-text-hover: #fafaf9; /* stone-50 */ + +/* Content Area */ +--bg-page: #fdfaf6; /* warm cream */ +--bg-card: #ffffff; /* white cards */ +--border-subtle: #e7e5e4; /* stone-200 */ +--text-primary: #1c1917; /* stone-900 */ +--text-secondary: #57534e; /* stone-600 */ +--text-muted: #a8a29e; /* stone-400 */ + +/* Accent */ +--accent: #059669; /* emerald-600 */ +--accent-hover: #047857; /* emerald-700 */ +--accent-light: #d1fae5; /* emerald-100 */ +--accent-text: #065f46; /* emerald-800 */ + +/* Status Colors */ +--status-pending: #f59e0b; /* amber-500 */ +--status-active: #059669; /* emerald-600 */ +--status-muted: #a8a29e; /* stone-400 */ + +/* Shadows */ +--shadow-card: 0 1px 3px rgba(28, 25, 23, 0.06), 0 1px 2px rgba(28, 25, 23, 0.04); +--shadow-card-hover: 0 4px 6px rgba(28, 25, 23, 0.08), 0 2px 4px rgba(28, 25, 23, 0.06); +``` + +### Typography + +```css +/* Headings: DM Sans (warm humanist) */ +--font-heading: 'DM Sans', system-ui, sans-serif; + +/* Body: DM Sans */ +--font-body: 'DM Sans', system-ui, sans-serif; + +/* Monospace: JetBrains Mono (for data tables, IDs, codes) */ +--font-mono: 'JetBrains Mono', 'Fira Code', monospace; + +/* Scale */ +--text-xs: 0.75rem; /* 12px */ +--text-sm: 0.875rem; /* 14px */ +--text-base: 1rem; /* 16px */ +--text-lg: 1.125rem; /* 18px */ +--text-xl: 1.25rem; /* 20px */ +--text-2xl: 1.5rem; /* 24px */ +--text-3xl: 1.875rem; /* 30px */ +--text-4xl: 2.25rem; /* 36px */ +``` + +### Spacing & Border Radius + +```css +--radius-sm: 0.375rem; /* 6px */ +--radius-md: 0.5rem; /* 8px */ +--radius-lg: 0.75rem; /* 12px */ +--radius-xl: 1rem; /* 16px */ + +--space-1: 0.25rem; /* 4px */ +--space-2: 0.5rem; /* 8px */ +--space-3: 0.75rem; /* 12px */ +--space-4: 1rem; /* 16px */ +--space-5: 1.25rem; /* 20px */ +--space-6: 1.5rem; /* 24px */ +--space-8: 2rem; /* 32px */ +--space-10: 2.5rem; /* 40px */ +--space-12: 3rem; /* 48px */ +``` + +### Sidebar Layout + +- Width: 240px (desktop), off-canvas on mobile +- Full height, fixed position +- Logo/brand area at top +- Nav items with icons + labels +- Settings collapsible section +- User role badge + sign out at bottom +- "Back to Site" link in logo area +- Active state: emerald background tint + emerald left border + emerald dot indicator + +### Content Area + +- Starts at `lg:pl-60` (sidebar width) on desktop +- Mobile: full width with hamburger trigger for sidebar +- Max content width: 1280px centered +- Page header with breadcrumb (optional per page) +- Card-based layouts for data +- Generous padding (space-10 or space-12 vertical) + +## Pages to Update + +1. **Admin Dashboard** (`/admin/page.tsx`) — Already has good structure, minor refinements +2. **Orders** (`/admin/orders/page.tsx`) — Convert from dark zinc to warm cream + monospace table +3. **Products** (`/admin/products/page.tsx`) — Light theme, consistent header +4. **Communications** (`/admin/communications/page.tsx`) — Light theme, consistent header +5. **Settings** (`/admin/settings/page.tsx`) — Light theme, consistent header +6. **All sub-pages** — Ensure consistent header style if they have one + +## Component Inventory + +### Sidebar Navigation +- Logo with "Back to Site" link +- Nav items: icon + label, active state with emerald indicator +- Collapsible Settings section +- Role badge +- Sign out button + +### Page Header +- Breadcrumb (optional): "Admin / Section" +- Page title: text-3xl, font-heading, bold +- Page description: text-secondary +- Action buttons (right-aligned) + +### Data Table +- Monospace font for all data cells +- Subtle border between rows +- Sticky header row +- Hover state on rows +- Status badges with appropriate colors +- Action buttons in last column + +### Cards +- White background +- Subtle shadow +- Rounded-xl corners +- Consistent padding + +### Buttons +- Primary: emerald bg, white text +- Secondary: white bg, stone border, stone text +- Ghost: transparent, hover shows bg + +### Form Inputs +- White background +- Stone border +- Focus: emerald border +- Monospace for codes/IDs + +## Implementation Notes + +1. Create CSS variables file at `src/styles/admin-tokens.css` +2. Import tokens in admin layout +3. Update sidebar component to use new styling +4. Update each page progressively +5. Orders table needs most work — dark theme → warm cream with monospace +6. Ensure mobile responsive behavior + +## Out of Scope + +- Backend logic changes +- Database schema changes +- Authentication flow changes +- Creating new pages +- Changing navigation items (already locked) \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/fix-agents.js b/fix-agents.js new file mode 100644 index 0000000..e1b9f74 --- /dev/null +++ b/fix-agents.js @@ -0,0 +1,9 @@ +// Patch global HTTP/HTTPS agents to use the correct outbound interface +// Fixes EADDRNOTAVAIL on macOS when Node.js can't find the right source address +const http = require('http'); +const https = require('https'); +http.globalAgent.options.localAddress = '192.168.50.71'; +http.globalAgent.options.keepAlive = false; +https.globalAgent.options.localAddress = '192.168.50.71'; +https.globalAgent.options.keepAlive = false; +console.log('Patched global agents: localAddress=192.168.50.71'); \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..9b2287e --- /dev/null +++ b/middleware.ts @@ -0,0 +1,64 @@ +import { NextResponse, type NextRequest } from "next/server"; + +const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000"; + +export async function middleware(request: NextRequest) { + const response = NextResponse.next({ request }); + + // ── Dev session bypass (enabled in all envs for demo) ────────────── + // Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/" + const devSession = request.cookies.get("dev_session")?.value; + const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee"; + const rcAuthUid = request.cookies.get("rc_auth_uid")?.value; + + let authUid: string | null = null; + + if (isDevMode) { + // Dev session only valid in development + authUid = DEV_UID; + } else if (rcAuthUid) { + // rc_auth_uid is set by /api/login — treat as authenticated + authUid = rcAuthUid; + } + // No rc_auth_uid in production → authUid stays null → redirect to /login + + const isAdmin = request.nextUrl.pathname.startsWith("/admin"); + const isLogin = request.nextUrl.pathname === "/login"; + + if (isAdmin && !authUid) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + // Auto-login for demo: no Supabase configured, no auth cookie present + if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) { + const url = request.nextUrl.clone(); + url.pathname = "/admin"; + url.searchParams.set("demo", "1"); + const response = NextResponse.redirect(url); + response.cookies.set("dev_session", "platform_admin", { + path: "/", + maxAge: 60 * 60 * 24, + httpOnly: true, + sameSite: "strict", + }); + return response; + } + const url = request.nextUrl.clone(); + url.pathname = "/login"; + return NextResponse.redirect(url); + } + + if (isLogin && authUid) { + const url = request.nextUrl.clone(); + url.pathname = "/admin"; + return NextResponse.redirect(url); + } + + return response; +} + +export const config = { + matcher: [ + "/admin/:path*", + "/admin", + "/login", + ], +}; \ No newline at end of file diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..b9ef91f --- /dev/null +++ b/netlify.toml @@ -0,0 +1,8 @@ +[build] + command = "git config --global submodule.recurse false && git submodule deinit --all 2>/dev/null; rm -rf .git/modules 2>/dev/null; npm run build" + publish = ".next" + +[build.environment] + NODE_VERSION = "20" + NEXT_TELEMETRY_DISABLED = "1" + GIT_LFS_ENABLED = "false" diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..13d5294 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,28 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + httpAgentOptions: { + keepAlive: false, + }, + images: { + remotePatterns: [ + { hostname: "wnzkhezyhnfzhkhiflrp.supabase.co" }, + { hostname: "images.unsplash.com" }, + { hostname: "cdn.shopify.com" }, + ], + }, + devIndicators: { + position: "bottom-left", + }, + experimental: { + serverActions: { + bodySizeLimit: '10mb', + }, + }, + serverExternalPackages: ['@prisma/adapter-pg'], + + // Netlify deployment settings + output: 'standalone', +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..455a221 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "route-commerce-platform", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0", + "build": "next build --webpack", + "start": "next start", + "lint": "eslint", + "migrate": "node supabase/push-migrations.js", + "migrate:one": "node supabase/push-migrations.js" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.96.0", + "@google/generative-ai": "^0.24.1", + "@gsap/react": "^2.1.2", + "@supabase/ssr": "^0.10.2", + "@supabase/supabase-js": "^2.105.3", + "exceljs": "^4.4.0", + "framer-motion": "^12.40.0", + "gsap": "^3.15.0", + "next": "^16.2.6", + "next-themes": "^0.4.6", + "openai": "^6.37.0", + "papaparse": "^5.5.3", + "pdf-lib": "^1.17.1", + "qrcode": "^1.5.4", + "react": "19.2.4", + "react-dom": "19.2.4", + "server-only": "^0.0.1", + "square": "^44.0.1", + "stripe": "^22.1.1", + "uuid": "^11.1.1" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/papaparse": "^5.5.2", + "@types/qrcode": "^1.5.6", + "@types/react": "^19", + "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "eslint": "^9", + "eslint-config-next": "16.2.5", + "pg": "^8.20.0", + "playwright": "^1.59.1", + "tailwindcss": "^4", + "typescript": "^5" + }, + "overrides": "{}" +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a96b010 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from "@playwright/test"; +import path from "path"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: "list", + use: { + baseURL: "https://route-commerce-platform.vercel.app", + trace: "on-first-retry", + }, + projects: [ + { + name: "production", + use: { + ...devices["Desktop Chrome"], + baseURL: "https://route-commerce-platform.vercel.app", + }, + }, + ], +}); diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/nav-test.html b/public/nav-test.html new file mode 100644 index 0000000..c1a3ca9 --- /dev/null +++ b/public/nav-test.html @@ -0,0 +1,19 @@ + + +Nav Test + +

Nav Test (plain HTML)

+

waiting

+ + + + diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/import-woo-to-route.ts b/scripts/import-woo-to-route.ts new file mode 100644 index 0000000..8133ef5 --- /dev/null +++ b/scripts/import-woo-to-route.ts @@ -0,0 +1,262 @@ +/** + * import-woo-to-route.ts - 2026 Season Import + * + * Reads Tuxedo_Corn_2026_Tour_Schedule-2.xlsx (Full Tour Schedule sheet) and produces: + * products.csv - 1 core pickup product + * stops.csv - 164 active stops + * + * Usage: + * npx tsx scripts/import-woo-to-route.ts \ + * --xlsx "/path/to/Tuxedo_Corn_2026_Tour_Schedule-2.xlsx" \ + * --brand 64294306-5f42-463d-a5e8-2ad6c81a96de \ + * --output ./import-output + */ + +import * as fs from "fs"; +import * as path from "path"; +import { parseArgs } from "util"; +import ExcelJS from "exceljs"; + +// Types + +interface ProductOutput { + name: string; + price: number; + type: "Pickup" | "Shipping" | "Pickup & Shipping"; + description: string; + active: boolean; + image_url: string; + is_taxable: boolean; +} + +interface StopOutput { + city: string; + state: string; + location: string; + date: string; + time: string; + address: string; + notes: string; +} + +function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +async function main() { + const args = parseArgs({ + options: { + xlsx: { type: "string" }, + brand: { type: "string" }, + output: { type: "string", default: "./import-output" }, + }, + }); + + const xlsxPath = args.values.xlsx as string; + const brandId = args.values.brand as string; + const outputDir = args.values.output as string; + + if (!xlsxPath || !brandId) { + console.error( + "Usage: npx tsx scripts/import-woo-to-route.ts --xlsx --brand [--output ]" + ); + process.exit(1); + } + if (!fs.existsSync(xlsxPath)) { + console.error("File not found: " + xlsxPath); + process.exit(1); + } + + // Read Excel + // Row 0=title, Row1=subtitle, Row2=color-key, Row3=column-headers, Row4+=data + const wb = new ExcelJS.Workbook(); + await wb.xlsx.readFile(xlsxPath); + const sheet = wb.getWorksheet(1); + if (!sheet) { + console.error("No worksheet found in file"); + process.exit(1); + } + + const allRows: (string | null | undefined)[][] = []; + sheet.eachRow((row) => { + allRows.push(row.values as (string | null | undefined)[]); + }); + + const colHeaders = (allRows[3] as string[]).map((h: unknown) => String(h ?? "").trim()); + console.log("Column headers:", colHeaders); + + const ci = (col: string) => colHeaders.indexOf(col); + const wkIdx = ci("Wk"), typeIdx = ci("Type"), datesIdx = ci("Dates"), dayIdx = ci("Day"); + const cityIdx = ci("City / Location"), hostIdx = ci("Host / Venue"); + const timeIdx = ci("Time"), truckIdx = ci("Truck"), statusIdx = ci("Status"), notesIdx = ci("Notes"); + + interface ScheduleRow { + wk: number; type: string; dates: string; day: string; + city: string; host: string; time: string; truck: string; status: string; notes: string; + } + + const scheduleRows: ScheduleRow[] = []; + let currentWeek: { wk: number; type: string; dates: string; day: string } | null = null; + + for (let i = 4; i < allRows.length; i++) { + const row = allRows[i] as (string | null | undefined)[]; + if (!row) continue; + + const wkVal = row[wkIdx]; + const Day = String(row[dayIdx] ?? "").trim(); + const City_Location = String(row[cityIdx] ?? "").trim(); + const Status = String(row[statusIdx] ?? "").trim(); + + if (wkVal !== null && wkVal !== undefined && String(wkVal).trim() !== "") { + currentWeek = { + wk: Number(wkVal), + type: String(row[typeIdx] ?? "").trim(), + dates: String(row[datesIdx] ?? "").trim(), + day: Day, + }; + } + + if (!currentWeek) continue; + if (Status === "Off day") continue; + if (!City_Location) continue; + + scheduleRows.push({ + wk: currentWeek.wk, type: currentWeek.type, dates: currentWeek.dates, + day: Day || currentWeek.day, + city: City_Location, + host: String(row[hostIdx] ?? "").trim(), + time: String(row[timeIdx] ?? "").trim(), + truck: String(row[truckIdx] ?? "").trim(), + status: Status, + notes: String(row[notesIdx] ?? "").trim(), + }); + } + + const ACTIVE_STATUSES = ["✓ Confirmed", "🔴 Pre-sale live"]; + const activeRows = scheduleRows.filter((r) => ACTIVE_STATUSES.includes(r.status)); + console.log("\nSchedule rows: " + scheduleRows.length + " total | " + activeRows.length + " active\n"); + + // PRODUCTS -- one core product for all stops + const products: ProductOutput[] = [ + { + name: "Olathe Sweet Corn -- 24 Ear Box", + price: 25, + type: "Pickup", + description: + "Fresh Olathe Sweet Corn pickup box. Add to cart, then choose your pickup stop at checkout. " + + "Available July-September 2026 at stops across Colorado, Wyoming, and New Mexico. " + + "See tuxedocorn.com for full schedule.", + active: true, + image_url: + "https://tuxedocorn.com/wp-content/uploads/2024/06/110260317_3494904527187819_6983832261179080791_n-1.jpg", + is_taxable: true, + }, + ]; + + console.log("[products] 1 core product: \"" + products[0].name + "\" at $" + products[0].price + "\n"); + + // STOPS -- one row per active schedule entry + const stops: StopOutput[] = []; + const seenStopKeys = new Set(); + + for (const row of activeRows) { + if (!row.city) continue; + + // Parse "City ST" or "City, ST" -> city + state + const cityRaw = row.city.replace(/--/g, "-").trim(); + let city = cityRaw, state = "CO"; + + if (cityRaw.includes(",")) { + const parts = cityRaw.split(",").map((s: string) => s.trim()); + const lastPart = parts[parts.length - 1] ?? ""; + if (/^[A-Z]{2}$/.test(lastPart)) { city = parts.slice(0, -1).join(", ").trim(); state = lastPart; } + else city = parts.join(", "); + } else { + const tokens = cityRaw.split(/\s+/); + const lastToken = tokens[tokens.length - 1] ?? ""; + if (/^[A-Z]{2}$/.test(lastToken)) { state = lastToken; city = tokens.slice(0, -1).join(" "); } + } + + const stopKey = slugify(city + "-" + state + "-" + row.dates + "-" + row.time); + if (seenStopKeys.has(stopKey)) continue; + seenStopKeys.add(stopKey); + + // Human-readable stop label = "Store (Time) | Truck T1" + const stopName = row.host + " (" + row.time + ")" + (row.truck ? " | Truck " + row.truck : ""); + + stops.push({ + city, + state, + location: stopName, + date: row.dates, // week range -- set actual date in /admin/stops after import + time: row.time, + address: row.city + " | " + row.host, + notes: row.day + (row.notes ? " | " + row.notes : ""), + }); + } + + console.log("[stops] " + stops.length + " stops written\n"); + + // Show sample by week + const byWeek: Record = {}; + for (const s of stops) { + if (!byWeek[s.date]) byWeek[s.date] = []; + byWeek[s.date].push(s); + } + const weeks = Object.keys(byWeek).slice(0, 4); + for (const week of weeks) { + const ws = byWeek[week]; + console.log(" " + week + " (" + ws.length + " stops):"); + for (const s of ws.slice(0, 3)) { + console.log(" " + s.city + ", " + s.state + " | \"" + s.location + "\" | " + s.time); + } + } + + // Write output CSVs + fs.mkdirSync(outputDir, { recursive: true }); + + function writeCSV(filename: string, header: string, rows: string[][]) { + const lines = rows.map((r) => + r.map((c) => "\"" + String(c ?? "").replace(/\"/g, "\"\"") + "\"").join(",") + ); + fs.writeFileSync(path.join(outputDir, filename), header + lines.join("\n")); + } + + writeCSV( + "products.csv", + "name,price,type,description,active,image_url,is_taxable\n", + products.map((p) => [ + p.name, String(p.price), p.type, p.description, + String(p.active), p.image_url, String(p.is_taxable), + ]) + ); + + writeCSV( + "stops.csv", + "city,state,location,date,time,address,notes\n", + stops.map((s) => [s.city, s.state, s.location, s.date, s.time, s.address, s.notes]) + ); + + // Summary + console.log("\n============================================================"); + console.log("IMPORT READY"); + console.log("============================================================"); + console.log("\nproducts.csv: 1 product (\"" + products[0].name + "\" @ $" + products[0].price + " Pickup)"); + console.log("stops.csv: " + stops.length + " stops (confirmed + pre-sale only)"); + console.log("\nOutput: " + outputDir + "/"); + console.log(""); + console.log(">>> NOTE: stops.csv 'date' field is a WEEK RANGE (e.g. Jul 22-28)."); + console.log("After importing stops, visit /admin/stops to set the actual date for each stop."); + console.log(""); + console.log("NEXT STEPS:"); + console.log(" 1. Upload " + outputDir + "/products.csv via /admin/import -> type: products"); + console.log(" 2. Upload " + outputDir + "/stops.csv via /admin/import -> type: stops"); + console.log(" 3. Visit /admin/stops -- set each stop's actual date, verify address/time"); + console.log(" 4. Visit /tuxedo -- verify product and stops appear\n"); + console.log("Re-run: npx tsx scripts/import-woo-to-route.ts --xlsx \"" + xlsxPath + "\" --brand " + brandId + " --output " + outputDir + "\n"); +} + +main().catch(console.error); diff --git a/scripts/seed.sh b/scripts/seed.sh new file mode 100644 index 0000000..25b5219 --- /dev/null +++ b/scripts/seed.sh @@ -0,0 +1,343 @@ +#!/bin/bash +# Seed script for Route Commerce Platform +# Run with: bash scripts/seed.sh + +set -e + +# Use service role key for write operations during seeding +SUPABASE_URL="${SUPABASE_URL:-https://your-project.supabase.co}" +SERVICE_KEY="${SUPABASE_SERVICE_ROLE_KEY}" +ANON_KEY="${ANON_KEY:-$NEXT_PUBLIC_SUPABASE_ANON_KEY}" + +AUTH_HEADER="apikey: $SERVICE_KEY" +CONTENT_TYPE="Content-Type: application/json" + +echo "Fetching brands..." +BRANDS=$(curl -s "$SUPABASE_URL/rest/v1/brands?select=id,name" \ + -H "$AUTH_HEADER") +echo "Brands: $BRANDS" + +TUXEDO_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +IRD_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | tail -1 | cut -d'"' -f4) + +echo "Tuxedo: $TUXEDO_ID" +echo "IRD: $IRD_ID" + +echo "" +echo "Creating stops..." + +STOP1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$TUXEDO_ID"'", + "city": "Burlington", + "state": "NC", + "date": "2026-05-15", + "time": "8:00 AM – 2:00 PM", + "location": "102 W Front St, Burlington, NC", + "slug": "burlington-nc-2026-05-15", + "active": true + }') +echo "Stop 1: $STOP1" + +STOP2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$TUXEDO_ID"'", + "city": "Greensboro", + "state": "NC", + "date": "2026-05-16", + "time": "8:00 AM – 2:00 PM", + "location": "200 S Elm St, Greensboro, NC", + "slug": "greensboro-nc-2026-05-16", + "active": true + }') +echo "Stop 2: $STOP2" + +STOP3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$IRD_ID"'", + "city": "Fort Pierce", + "state": "FL", + "date": "2026-05-15", + "time": "7:00 AM – 1:00 PM", + "location": "1224 N US-1, Fort Pierce, FL", + "slug": "fort-pierce-fl-2026-05-15", + "active": true + }') +echo "Stop 3: $STOP3" + +echo "" +echo "Creating products..." + +PROD1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$TUXEDO_ID"'", + "name": "Sweet Corn", + "description": "Fresh picked white sweet corn, 8 ears per bag", + "price": 12, + "type": "Sweet Corn", + "active": true + }') +echo "Prod 1: $PROD1" + +PROD2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$TUXEDO_ID"'", + "name": "Peaches", + "description": "Just-peeled freestone peaches, pint jar", + "price": 8, + "type": "Fruit", + "active": true + }') +echo "Prod 2: $PROD2" + +PROD3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$TUXEDO_ID"'", + "name": "Corn & Peach Bundle", + "description": "Sweet corn + peaches combo, save $2", + "price": 18, + "type": "Bundle", + "active": true + }') +echo "Prod 3: $PROD3" + +PROD4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$IRD_ID"'", + "name": "Navel Oranges", + "description": "Premium navel oranges, 4 lb bag", + "price": 15, + "type": "Citrus", + "active": true + }') +echo "Prod 4: $PROD4" + +PROD5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$IRD_ID"'", + "name": "Grapefruit", + "description": "Ruby red grapefruit, 3 lb bag", + "price": 12, + "type": "Citrus", + "active": true + }') +echo "Prod 5: $PROD5" + +PROD6=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "brand_id": "'"$IRD_ID"'", + "name": "Citrus Sampler", + "description": "Oranges, grapefruit & tangelos, 6 lb mix", + "price": 22, + "type": "Bundle", + "active": true + }') +echo "Prod 6: $PROD6" + +P1_ID=$(echo "$PROD1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +P2_ID=$(echo "$PROD2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +P3_ID=$(echo "$PROD3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +P4_ID=$(echo "$PROD4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +P5_ID=$(echo "$PROD5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +P6_ID=$(echo "$PROD6" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + +S1_ID=$(echo "$STOP1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +S2_ID=$(echo "$STOP2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +S3_ID=$(echo "$STOP3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + +echo "" +echo "Assigning products to stops..." + +curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P2_ID"'"}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P4_ID"'"}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P5_ID"'"}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P6_ID"'"}' > /dev/null + +echo "" +echo "Creating orders..." + +ORDER1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "customer_name": "Sarah Mitchell", + "customer_email": "sarah.m@email.com", + "customer_phone": "(336) 555-0142", + "stop_id": "'"$S1_ID"'", + "status": "pending", + "subtotal": 32, + "discount_amount": 0, + "pickup_complete": false, + "payment_processor": "manual", + "payment_status": "paid" + }') +echo "Order 1: $ORDER1" + +ORDER2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "customer_name": "James Rivera", + "customer_email": "jrivera@email.com", + "customer_phone": "(336) 555-0298", + "stop_id": "'"$S1_ID"'", + "status": "confirmed", + "subtotal": 22, + "discount_amount": 2, + "discount_reason": "First-time customer", + "pickup_complete": true, + "payment_processor": "manual", + "payment_status": "paid" + }') +echo "Order 2: $ORDER2" + +ORDER3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "customer_name": "Lisa Chen", + "customer_phone": "(919) 555-0763", + "stop_id": "'"$S2_ID"'", + "status": "pending", + "subtotal": 12, + "pickup_complete": false, + "payment_processor": "manual", + "payment_status": "pending" + }') +echo "Order 3: $ORDER3" + +ORDER4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "customer_name": "Marcus Thompson", + "customer_email": "mthompson@email.com", + "customer_phone": "(772) 555-0119", + "stop_id": "'"$S3_ID"'", + "status": "confirmed", + "subtotal": 37, + "pickup_complete": false, + "payment_processor": "venmo", + "payment_status": "paid", + "payment_transaction_id": "VEN-88420" + }') +echo "Order 4: $ORDER4" + +ORDER5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \ + -H "$AUTH_HEADER" \ + -H "$CONTENT_TYPE" \ + -H "Prefer: return=representation" \ + -d '{ + "customer_name": "Amanda Foster", + "customer_email": "afoster@email.com", + "stop_id": "'"$S3_ID"'", + "status": "cancelled", + "subtotal": 15, + "pickup_complete": false, + "internal_notes": "Customer cancelled after hours, no show next day", + "payment_processor": "manual", + "payment_status": "refunded", + "refunded_amount": 15, + "refund_reason": "Customer cancelled" + }') +echo "Order 5: $ORDER5" + +O1_ID=$(echo "$ORDER1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +O2_ID=$(echo "$ORDER2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +O3_ID=$(echo "$ORDER3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +O4_ID=$(echo "$ORDER4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) +O5_ID=$(echo "$ORDER5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + +echo "" +echo "Creating order items..." + +curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P2_ID"'", "quantity": 1, "price": 8}' > /dev/null + +curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P3_ID"'", "quantity": 1, "price": 18}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null + +curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"order_id": "'"$O3_ID"'", "product_id": "'"$P1_ID"'", "quantity": 1, "price": 12}' > /dev/null + +curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P4_ID"'", "quantity": 2, "price": 15}' > /dev/null +curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P5_ID"'", "quantity": 1, "price": 12}' > /dev/null + +curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \ + -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \ + -d '{"order_id": "'"$O5_ID"'", "product_id": "'"$P4_ID"'", "quantity": 1, "price": 15}' > /dev/null + +echo "" +echo "Done! Seed data created:" +echo " 3 stops (Burlington NC, Greensboro NC, Fort Pierce FL)" +echo " 6 products (3 Tuxedo Corn, 3 Indian River Direct)" +echo " 5 orders with items" +echo "" +echo "Quick test URLs:" +echo " /admin/orders" +echo " /admin/products" +echo " /admin/stops" diff --git a/scripts/upload-tuxedo-video.mjs b/scripts/upload-tuxedo-video.mjs new file mode 100644 index 0000000..5890e08 --- /dev/null +++ b/scripts/upload-tuxedo-video.mjs @@ -0,0 +1,74 @@ +import { createClient } from "@supabase/supabase-js"; +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import path from "path"; + +// Load .env.local manually +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const envPath = path.join(__dirname, "..", ".env.local"); +const envContent = readFileSync(envPath, "utf8"); +envContent.split("\n").forEach((line) => { + const [key, ...rest] = line.split("="); + if (key && rest.length && key.trim() && !key.trim().startsWith("#")) { + process.env[key.trim()] = rest.join("=").trim().replace(/^"|"$/g, ""); + } +}); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error("Missing env vars"); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); + +async function uploadTuxedoHeroVideo() { + const filePath = "/Users/kylemartinez/Downloads/062624TuxedoCorn 001.mp4"; + const fileBuffer = readFileSync(filePath); + const fileSizeMB = (fileBuffer.byteLength / 1024 / 1024).toFixed(2); + console.log(`File: ${filePath} (${fileSizeMB} MB)`); + + // Ensure public videos bucket exists + const { data: buckets, error: listErr } = await supabase.storage.listBuckets(); + if (listErr) { console.error("List buckets error:", listErr.message); process.exit(1); } + + const existing = buckets?.find((b) => b.name === "videos"); + if (!existing) { + console.log("Creating 'videos' bucket (public)..."); + const { error: createErr } = await supabase.storage.createBucket("videos", { public: true }); + if (createErr) { console.error("Create bucket error:", createErr.message); process.exit(1); } + console.log("Bucket 'videos' created."); + } else { + console.log(`'videos' bucket exists (public: ${existing.public}).`); + if (!existing.public) { + const { error: updErr } = await supabase.storage.updateBucket("videos", { public: true }); + if (updErr) console.warn("Could not update bucket to public:", updErr.message); + else console.log("Bucket updated to public."); + } + } + + // Upload + console.log("Uploading tuxedo-hero.mp4..."); + const { data: uploadData, error: uploadErr } = await supabase.storage + .from("videos") + .upload("tuxedo-hero.mp4", fileBuffer, { + contentType: "video/mp4", + upsert: true, + }); + + if (uploadErr) { + console.error("Upload error:", uploadErr.message); + process.exit(1); + } + + console.log("Upload OK:", JSON.stringify(uploadData)); + + // Public URL + const { data: urlData } = supabase.storage.from("videos").getPublicUrl("tuxedo-hero.mp4"); + const url = urlData.publicUrl; + console.log(`\n✅ Public URL:\n${url}`); +} + +uploadTuxedoHeroVideo().catch((e) => { console.error(e); process.exit(1); }); \ No newline at end of file diff --git a/src/actions/admin-user.ts b/src/actions/admin-user.ts new file mode 100644 index 0000000..fe66803 --- /dev/null +++ b/src/actions/admin-user.ts @@ -0,0 +1,7 @@ +"use server"; + +import { getAdminUser, type AdminUser } from "@/lib/admin-permissions"; + +export async function getCurrentAdminUser(): Promise { + return getAdminUser(); +} \ No newline at end of file diff --git a/src/actions/admin/audit.ts b/src/actions/admin/audit.ts new file mode 100644 index 0000000..39465a2 --- /dev/null +++ b/src/actions/admin/audit.ts @@ -0,0 +1,64 @@ +"use server"; + +import { createClient as createServiceClient } from "@supabase/supabase-js"; + +function getServiceClient() { + const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); + return createServiceClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + roleKey, + ); +} + +type AdminActionPayload = { + action_type: "create" | "update" | "delete"; + admin_id?: string; + admin_email?: string; + affected_user_id?: string; + brand_id?: string; + details?: Record; +}; + +type UserActivityPayload = { + user_id: string; + activity_type: "login" | "logout" | "password_change" | "profile_update" | "email_change"; + details?: Record; + ip_address?: string; + user_agent?: string; +}; + +export async function logAdminAction(payload: AdminActionPayload): Promise { + try { + const service = getServiceClient(); + await service.rpc("log_admin_action", { + p_payload: { + action_type: payload.action_type, + admin_id: payload.admin_id ?? null, + admin_email: payload.admin_email ?? null, + affected_user_id: payload.affected_user_id ?? null, + brand_id: payload.brand_id ?? null, + details: payload.details ?? {}, + }, + }); + } catch (e) { + // logging failed silently + } +} + +export async function logUserActivity(payload: UserActivityPayload): Promise { + try { + const service = getServiceClient(); + await service.rpc("log_user_activity", { + p_payload: { + user_id: payload.user_id, + activity_type: payload.activity_type, + details: payload.details ?? {}, + ip_address: payload.ip_address ?? null, + user_agent: payload.user_agent ?? null, + }, + }); + } catch (e) { + // logging failed silently + } +} diff --git a/src/actions/admin/force-login.ts b/src/actions/admin/force-login.ts new file mode 100644 index 0000000..e70dbec --- /dev/null +++ b/src/actions/admin/force-login.ts @@ -0,0 +1,63 @@ +"use server"; + +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001"; + +export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> { + const cookieStore = await cookies(); + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = NextResponse.next(); + const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { + cookies: { + getAll() { return cookieStore.getAll(); }, + setAll(cookiesToSet, headers) { + cookiesToSet.forEach(({ name, value, options }) => { + response.cookies.set(name, value, options); + }); + Object.entries(headers).forEach(([key, value]) => { + response.headers.set(key, value); + }); + }, + }, + }); + + // Upsert dev platform_admin record + const { data: existing } = await supabase + .from("admin_users") + .select("id, role") + .eq("user_id", DEV_ADMIN_UID) + .single(); + + if (!existing) { + const { error: insertError } = await supabase + .from("admin_users") + .insert({ + user_id: DEV_ADMIN_UID, + brand_id: null, + role: "platform_admin", + active: true, + can_manage_products: true, + can_manage_stops: true, + can_manage_orders: true, + can_manage_pickup: true, + can_manage_messages: true, + can_manage_refunds: true, + can_manage_users: true, + can_manage_water_log: true, + can_manage_reports: true, + can_manage_settings: true, + must_change_password: false, + }); + + if (insertError) { + return { success: false, error: insertError.message }; + } + } + + return { success: true, uid: DEV_ADMIN_UID }; +} diff --git a/src/actions/admin/password.ts b/src/actions/admin/password.ts new file mode 100644 index 0000000..24e317e --- /dev/null +++ b/src/actions/admin/password.ts @@ -0,0 +1,30 @@ +"use server"; + +import { cookies } from "next/headers"; +import { createClient as createServiceClient } from "@supabase/supabase-js"; + +export async function updatePasswordAction( + newPassword: string +): Promise<{ error?: string }> { + const cookieStore = await cookies(); + const uid = + cookieStore.get("rc_auth_uid")?.value ?? + cookieStore.get("rc_uid")?.value; + + if (!uid) { + return { error: "Not authenticated. Please log in again." }; + } + + const service = createServiceClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + const { error } = await service.rpc("update_user_password", { + p_user_id: uid, + p_password: newPassword, + }); + + if (error) return { error: error.message }; + return {}; +} \ No newline at end of file diff --git a/src/actions/admin/reset-admin.ts b/src/actions/admin/reset-admin.ts new file mode 100644 index 0000000..3607240 --- /dev/null +++ b/src/actions/admin/reset-admin.ts @@ -0,0 +1,51 @@ +"use server"; + +import { createClient as createServiceClient } from "@supabase/supabase-js"; + +function getServiceClient() { + const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set"); + return createServiceClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + roleKey, + ); +} + +export type ResetAdminPasswordResult = + | { success: true; tempPassword: string } + | { success: false; error: string }; + +/** + * Emergency recovery action — only usable in development or when the caller + * already has service role access. Resets the password for the specified email + * and returns the temp password so it can be displayed to the user. + */ +export async function resetAdminPassword( + email: string, + newPassword: string +): Promise { + const service = getServiceClient(); + + // Look up auth user by email + const { data: authUsers, error: listError } = await service.auth.admin.listUsers(); + if (listError || !authUsers?.users) { + return { success: false, error: "Could not list users: " + listError?.message }; + } + + const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase()); + if (!authUser) { + return { success: false, error: "No auth user found for that email address." }; + } + + // Update password via service role + const { error: updateError } = await service.auth.admin.updateUserById( + authUser.id, + { password: newPassword, email_confirm: true } + ); + + if (updateError) { + return { success: false, error: updateError.message }; + } + + return { success: true, tempPassword: newPassword }; +} \ No newline at end of file diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts new file mode 100644 index 0000000..af0c288 --- /dev/null +++ b/src/actions/admin/users.ts @@ -0,0 +1,672 @@ +"use server"; + +import { cookies, headers } from "next/headers"; +import { createServerClient } from "@supabase/ssr"; +import { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { createClient as createServiceClient } from "@supabase/supabase-js"; +import { supabase as publicSupabase } from "@/lib/supabase"; +import { getMockTableData, mockBrands } from "@/lib/mock-data"; + +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; + +export type AdminUserRow = { + id: string; + user_id: string; + display_name: string | null; + email: string; + phone_number: string | null; + role: "platform_admin" | "brand_admin" | "store_employee" | "staff"; + brand_id: string | null; + brand_name: string | null; + can_manage_products: boolean; + can_manage_stops: boolean; + can_manage_orders: boolean; + can_manage_pickup: boolean; + can_manage_messages: boolean; + can_manage_refunds: boolean; + can_manage_users: boolean; + can_manage_water_log: boolean; + can_manage_reports: boolean; + active: boolean; + must_change_password: boolean; + created_at: string; + last_login: string | null; +}; + +export type CreateAdminUserInput = { + email: string; + password: string; + role: "platform_admin" | "brand_admin" | "store_employee"; + brand_id: string | null; + display_name?: string; + phone_number?: string; + flags: { + can_manage_products?: boolean; + can_manage_stops?: boolean; + can_manage_orders?: boolean; + can_manage_pickup?: boolean; + can_manage_messages?: boolean; + can_manage_refunds?: boolean; + can_manage_users?: boolean; + can_manage_water_log?: boolean; + can_manage_reports?: boolean; + }; + mustChangePassword?: boolean; +}; + +export type UpdateAdminUserInput = { + id: string; + role?: "platform_admin" | "brand_admin" | "store_employee"; + brand_id?: string | null; + flags?: Partial<{ + can_manage_products: boolean; + can_manage_stops: boolean; + can_manage_orders: boolean; + can_manage_pickup: boolean; + can_manage_messages: boolean; + can_manage_refunds: boolean; + can_manage_users: boolean; + can_manage_water_log: boolean; + can_manage_reports: boolean; + }>; + active?: boolean; + display_name?: string | null; + phone_number?: string | null; +}; + +// ─── SSR client for authenticated requests ───────────────────────────────── + +async function getAuthClient() { + const cookieStore = await cookies(); + const headerStore = await headers(); + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const request = new NextRequest("http://localhost/admin", { headers: new Headers() }); + const response = NextResponse.next({ request }); + + // Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets + // cookies that arrive in the header but NOT in next/headers cookies()). + const cookieHeader = headerStore.get("cookie") || ""; + const allCookies = cookieHeader.split(";").map(c => c.trim()); + const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid=")); + const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null; + + const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { + cookies: { + getAll() { return cookieStore.getAll(); }, + setAll(cookiesToSet, headers) { + cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options)); + Object.entries(headers).forEach(([key, value]) => response.headers.set(key, value)); + }, + }, + }); + return { supabase, response, rcAuthUid }; +} + +async function callRpcWithAuth(fn: string, params: Record): Promise<{ data: T | null; error: string | null }> { + const { supabase, rcAuthUid } = await getAuthClient(); + + // Dev force-login UID bypasses Supabase auth entirely + const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001"; + if (rcAuthUid === DEV_FORCE_UID) { + return { data: null, error: null }; // let the action proceed without auth check + } + + const { data: userData, error: userError } = await supabase.auth.getUser(); + if (userError || !userData.user) { + return { data: null, error: "Not authenticated" }; + } + const { data, error } = await supabase.rpc(fn, params as Record); + if (error) { /* RPC error handled silently */ } + return { data: data as T, error: error ? error.message : null }; +} + +// ─── Service role client (server-only, never exposed to browser) ─────────── + +function getServiceClient() { + const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!roleKey) { + throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path."); + } + return createServiceClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + roleKey, + ); +} + +// ─── Dev-only path — uses service role to create auth user + admin_users ── + +async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { + if (process.env.NODE_ENV === "production") { + return { user: null, error: "Dev path not available in production" }; + } + const cookieStore = await cookies(); + const devSession = cookieStore.get("dev_session")?.value; + if (!devSession || devSession !== "platform_admin") { + return { user: null, error: "Not authenticated" }; + } + + const service = getServiceClient(); + + // Create auth user with the provided password + const { data: authUser, error: authError } = await service.auth.admin.createUser({ + email: input.email, + password: input.password, + email_confirm: true, + user_metadata: { + display_name: input.display_name || input.email.split("@")[0], + phone_number: input.phone_number ?? null, + }, + }); + if (authError || !authUser.user) { + return { user: null, error: authError?.message ?? "Failed to create auth user" }; + } + + // Insert into admin_users + const { data: inserted, error: insertError } = await service + .from("admin_users") + .insert({ + user_id: authUser.user.id, + role: input.role, + brand_id: input.brand_id, + display_name: input.display_name || input.email.split("@")[0], + phone_number: input.phone_number ?? null, + can_manage_products: input.flags.can_manage_products ?? false, + can_manage_stops: input.flags.can_manage_stops ?? false, + can_manage_orders: input.flags.can_manage_orders ?? false, + can_manage_pickup: input.flags.can_manage_pickup ?? false, + can_manage_messages: input.flags.can_manage_messages ?? false, + can_manage_refunds: input.flags.can_manage_refunds ?? false, + can_manage_users: input.flags.can_manage_users ?? false, + can_manage_water_log: input.flags.can_manage_water_log ?? false, + can_manage_reports: input.flags.can_manage_reports ?? false, + active: true, + must_change_password: input.mustChangePassword ?? true, + }) + .select() + .single(); + + if (insertError) { + return { user: null, error: insertError.message }; + } + + // Send welcome email + try { + const { sendWelcomeEmail } = await import("@/lib/email-service"); + const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role; + await sendWelcomeEmail({ + to: input.email, + name: input.display_name || input.email.split("@")[0], + role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee", + brandName: "Tuxedo Corn", + tempPassword: input.password, + }); + } catch (e) { + // welcome email failed silently + } + + return { + user: { + id: inserted.id, + user_id: inserted.user_id, + display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0], + email: input.email, + phone_number: inserted.phone_number ?? input.phone_number ?? null, + role: inserted.role, + brand_id: inserted.brand_id, + brand_name: null, + can_manage_products: inserted.can_manage_products, + can_manage_stops: inserted.can_manage_stops, + can_manage_orders: inserted.can_manage_orders, + can_manage_pickup: inserted.can_manage_pickup, + can_manage_messages: inserted.can_manage_messages, + can_manage_refunds: inserted.can_manage_refunds, + can_manage_users: inserted.can_manage_users, + can_manage_water_log: inserted.can_manage_water_log, + can_manage_reports: inserted.can_manage_reports, + active: inserted.active, + must_change_password: inserted.must_change_password ?? true, + created_at: inserted.created_at, + last_login: null, + }, + error: null, + }; +} + +function mapUserRow(row: Record): AdminUserRow { + return { + id: String(row.id ?? ""), + user_id: String(row.user_id ?? ""), + display_name: (row.display_name as string | null) ?? null, + email: String(row.email ?? ""), + phone_number: (row.phone_number as string | null) ?? null, + role: (row.role as AdminUserRow["role"]) ?? "store_employee", + brand_id: (row.brand_id as string | null) ?? null, + brand_name: (row.brand_name as string | null) ?? null, + can_manage_products: Boolean(row.can_manage_products ?? false), + can_manage_stops: Boolean(row.can_manage_stops ?? false), + can_manage_orders: Boolean(row.can_manage_orders ?? false), + can_manage_pickup: Boolean(row.can_manage_pickup ?? false), + can_manage_messages: Boolean(row.can_manage_messages ?? false), + can_manage_refunds: Boolean(row.can_manage_refunds ?? false), + can_manage_users: Boolean(row.can_manage_users ?? false), + can_manage_water_log: Boolean(row.can_manage_water_log ?? false), + can_manage_reports: Boolean(row.can_manage_reports ?? false), + active: Boolean(row.active ?? true), + must_change_password: Boolean(row.must_change_password ?? false), + created_at: String(row.created_at ?? ""), + last_login: (row.last_login as string | null) ?? null, + }; +} + +// ─── Dev path helpers (service role, local only) ─────────────────────────── + +async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { + const service = getServiceClient(); + + // Ensure caller has an admin_users record + if (callerUid) { + const { data: existing } = await service + .from("admin_users") + .select("id") + .eq("user_id", callerUid) + .maybeSingle(); + + if (!existing) { + // auto-creating admin_users for uid + const { data: authData } = await service.auth.admin.listUsers(); + const authUser = authData?.users?.find((u) => u.id === callerUid); + const meta = (authUser as { user_metadata?: Record })?.user_metadata; + await service.from("admin_users").insert({ + user_id: callerUid, + role: "platform_admin", + brand_id: null, + display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin", + phone_number: (meta?.phone_number as string | null) ?? null, + can_manage_products: true, + can_manage_stops: true, + can_manage_orders: true, + can_manage_pickup: true, + can_manage_messages: true, + can_manage_refunds: true, + can_manage_users: true, + can_manage_water_log: true, + can_manage_reports: true, + active: true, + must_change_password: false, + }); + } + } + + // Fetch all admin_users rows (no RLS for service role) + const { data: adminRows, error: adminError } = await service + .from("admin_users") + .select(` + id, user_id, role, brand_id, active, must_change_password, created_at, last_login, + can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, + can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports, + brands (name) + `) + .order("created_at", { ascending: false }); + + if (adminError) return { users: [], error: adminError.message }; + + // Fetch auth user details via service role admin API + const { data: authData, error: authError } = await service.auth.admin.listUsers(); + if (authError) return { users: [], error: authError.message }; + + const authMap: Record = {}; + (authData?.users ?? []).forEach((u) => { + const user = u as { id: string; email?: string; user_metadata?: Record }; + authMap[user.id] = { + email: user.email ?? "", + display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null, + phone_number: (user.user_metadata?.phone_number as string | null) ?? null, + }; + }); + + const users: AdminUserRow[] = (adminRows ?? []).map((row) => { + const r = row as Record & { brands?: { name?: string } }; + const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null }; + return { + ...mapUserRow(r), + email: authInfo.email || "No Email", + display_name: authInfo.display_name ?? null, + phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null, + brand_name: r.brands?.name ?? null, + }; + }); + + return { users, error: null }; +} + +function buildUsersFromRows(adminRows: Record[], authUsers: { id: string; email?: string; user_metadata?: Record }[]): { users: AdminUserRow[]; error: string | null } { + const authMap: Record = {}; + (authUsers ?? []).forEach((u) => { + authMap[u.id] = { + email: u.email ?? "", + display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null, + phone_number: (u.user_metadata?.phone_number as string | null) ?? null, + }; + }); + + const users: AdminUserRow[] = adminRows.map((row) => { + const r = row as Record & { brands?: { name?: string } }; + const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null }; + return { + ...mapUserRow(r), + email: authInfo.email || "No Email", + display_name: authInfo.display_name ?? null, + phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null, + brand_name: r.brands?.name ?? null, + }; + }); + + return { users, error: null }; +} + +// ─── Production admin actions (require real Supabase auth) ───────────────── + +const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001"; + +export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { + if (useMockData) { + const mockUsers = getMockTableData("users") as AdminUserRow[]; + let filteredUsers = mockUsers; + if (brandId) { + filteredUsers = mockUsers.filter(u => u.brand_id === brandId); + } + return { users: filteredUsers, error: null }; + } + + const cookieStore = await cookies(); + const headerStore = await headers(); + const devSession = cookieStore.get("dev_session")?.value; + + // Read rc_auth_uid for force-login check + const cookieHeader = headerStore.get("cookie") || ""; + const rcAuthUid = cookieHeader.split(";").map(c => c.trim()) + .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; + + // In development mode: ALL requests with a valid rc_auth_uid use the dev/service path. + // This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation. + if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) { + return devListAdminUsers(rcAuthUid); + } + + // Dev session cookie (platform_admin/brand_admin) — always use service role path + const isDevAdmin = process.env.NODE_ENV !== "production" && ( + devSession === "platform_admin" || devSession === "brand_admin" || rcAuthUid === DEV_FORCE_UID + ); + if (isDevAdmin) { + return devListAdminUsers(rcAuthUid ?? undefined); + } + + // Production path: try authenticated RPC first, fall back to service role if not authenticated + const result = await callRpcWithAuth("get_admin_users", { p_brand_id: brandId ?? null }); + if (result.error === "Not authenticated" && rcAuthUid) { + // No Supabase session token in browser — use service role with rc_auth_uid + const service = getServiceClient(); + const { data: adminRows, error: adminError } = await service + .from("admin_users") + .select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login, + can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup, + can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports, + brands (name)`) + .order("created_at", { ascending: false }); + if (adminError) return { users: [], error: adminError.message }; + const { data: authData } = await service.auth.admin.listUsers(); + const authMap: Record = {}; + (authData?.users ?? []).forEach((u) => { + const user = u as { id: string; email?: string; user_metadata?: Record }; + authMap[user.id] = { + email: user.email ?? "", + display_name: (user.user_metadata?.display_name as string | null) ?? null, + phone_number: (user.user_metadata?.phone_number as string | null) ?? null, + }; + }); + const users: AdminUserRow[] = (adminRows ?? []).map((row) => { + const r = row as Record & { brands?: { name?: string } }; + const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null }; + return { + ...mapUserRow(r), + email: authInfo.email || "No Email", + display_name: authInfo.display_name ?? null, + phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null, + brand_name: r.brands?.name ?? null, + }; + }); + return { users, error: null }; + } + return { users: result.data ?? [], error: result.error }; +} + +export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { + // Read auth context + const cookieStore = await cookies(); + const headerStore = await headers(); + const devSession = cookieStore.get("dev_session")?.value; + const cookieHeader = headerStore.get("cookie") || ""; + const rcAuthUid = cookieHeader.split(";").map(c => c.trim()) + .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; + + // DEV_FORCE_UID bypass — set by Emergency Force Login or /api/force-admin + if (rcAuthUid === DEV_FORCE_UID) { + return devCreateAdminUser(input); + } + + const isDevAdmin = process.env.NODE_ENV !== "production" && devSession === "platform_admin"; + + // Dev path: use service role to create user without Supabase auth session + if (isDevAdmin) { + return devCreateAdminUser(input); + } + + // Production path — use service role directly (bypasses Supabase JWT auth) + // rc_auth_uid cookie proves the admin is logged in; service role creates the account + if (rcAuthUid) { + const service = getServiceClient(); + + // Create auth user + const { data: authUser, error: authError } = await service.auth.admin.createUser({ + email: input.email, + password: input.password, + email_confirm: true, + user_metadata: { + display_name: input.display_name || input.email.split("@")[0], + phone_number: input.phone_number ?? null, + }, + }); + if (authError || !authUser.user) { + return { user: null, error: authError?.message ?? "Failed to create auth user" }; + } + + // Insert into admin_users + const { data: inserted, error: insertError } = await service + .from("admin_users") + .insert({ + user_id: authUser.user.id, + role: input.role, + brand_id: input.brand_id, + display_name: input.display_name || input.email.split("@")[0], + phone_number: input.phone_number ?? null, + can_manage_products: input.flags.can_manage_products ?? false, + can_manage_stops: input.flags.can_manage_stops ?? false, + can_manage_orders: input.flags.can_manage_orders ?? false, + can_manage_pickup: input.flags.can_manage_pickup ?? false, + can_manage_messages: input.flags.can_manage_messages ?? false, + can_manage_refunds: input.flags.can_manage_refunds ?? false, + can_manage_users: input.flags.can_manage_users ?? false, + can_manage_water_log: input.flags.can_manage_water_log ?? false, + can_manage_reports: input.flags.can_manage_reports ?? false, + active: true, + must_change_password: input.mustChangePassword ?? true, + }) + .select() + .single(); + + if (insertError) return { user: null, error: insertError.message }; + + // Send welcome email + try { + const { sendWelcomeEmail } = await import("@/lib/email-service"); + const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role; + await sendWelcomeEmail({ + to: input.email, + name: input.display_name || input.email.split("@")[0], + role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee", + brandName: "Tuxedo Corn", + tempPassword: input.password, + }); + } catch (e) { + // welcome email failed silently + } + + return { + user: { + id: inserted.id, + user_id: inserted.user_id, + display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0], + email: input.email, + phone_number: inserted.phone_number ?? input.phone_number ?? null, + role: inserted.role, + brand_id: inserted.brand_id, + brand_name: null, + can_manage_products: inserted.can_manage_products, + can_manage_stops: inserted.can_manage_stops, + can_manage_orders: inserted.can_manage_orders, + can_manage_pickup: inserted.can_manage_pickup, + can_manage_messages: inserted.can_manage_messages, + can_manage_refunds: inserted.can_manage_refunds, + can_manage_users: inserted.can_manage_users, + can_manage_water_log: inserted.can_manage_water_log, + can_manage_reports: inserted.can_manage_reports, + active: inserted.active, + must_change_password: inserted.must_change_password ?? true, + created_at: inserted.created_at, + last_login: null, + }, + error: null, + }; + } + + return { user: null, error: "Not authenticated" }; +} + +export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { + // Dev bypass check + const cookieStore = await cookies(); + const headerStore = await headers(); + const devSession = cookieStore.get("dev_session")?.value; + const cookieHeader = headerStore.get("cookie") || ""; + const rcAuthUid = cookieHeader.split(";").map(c => c.trim()) + .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; + // DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly + if (rcAuthUid === DEV_FORCE_UID) { + const service = getServiceClient(); + const { data, error } = await service + .from("admin_users") + .update({ + role: input.role ?? undefined, + brand_id: input.brand_id ?? undefined, + can_manage_products: input.flags?.can_manage_products ?? undefined, + can_manage_stops: input.flags?.can_manage_stops ?? undefined, + can_manage_orders: input.flags?.can_manage_orders ?? undefined, + can_manage_pickup: input.flags?.can_manage_pickup ?? undefined, + can_manage_messages: input.flags?.can_manage_messages ?? undefined, + can_manage_refunds: input.flags?.can_manage_refunds ?? undefined, + can_manage_users: input.flags?.can_manage_users ?? undefined, + can_manage_water_log: input.flags?.can_manage_water_log ?? undefined, + can_manage_reports: input.flags?.can_manage_reports ?? undefined, + active: input.active ?? undefined, + display_name: input.display_name ?? null, + phone_number: input.phone_number ?? null, + }) + .eq("id", input.id) + .select() + .single(); + if (error) return { user: null, error: error.message }; + return { user: mapUserRow(data), error: null }; + } + + const result = await callRpcWithAuth("update_admin_user", { + p_id: input.id, + p_role: input.role ?? null, + p_brand_id: input.brand_id ?? null, + p_flags: input.flags ?? null, + p_active: input.active ?? null, + p_display_name: input.display_name ?? null, + p_phone_number: input.phone_number ?? null, + }); + const rows = result.data as AdminUserRow[] | null; + return { user: rows?.[0] ?? null, error: result.error }; +} + +export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> { + // Dev bypass check + const cookieStore = await cookies(); + const headerStore = await headers(); + const devSession = cookieStore.get("dev_session")?.value; + const cookieHeader = headerStore.get("cookie") || ""; + const rcAuthUid = cookieHeader.split(";").map(c => c.trim()) + .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; + // DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly + if (rcAuthUid === DEV_FORCE_UID) { + const service = getServiceClient(); + // Get user_id first + const { data: adminRow, error: fetchError } = await service + .from("admin_users") + .select("user_id") + .eq("id", id) + .single(); + if (fetchError) return { success: false, error: fetchError.message }; + // Delete from admin_users + const { error: deleteError } = await service.from("admin_users").delete().eq("id", id); + if (deleteError) return { success: false, error: deleteError.message }; + // Delete auth user + if (adminRow?.user_id) { + await service.auth.admin.deleteUser(adminRow.user_id); + } + return { success: true, error: null }; + } + + const result = await callRpcWithAuth("delete_admin_user", { p_id: id }); + return { success: result.data ?? false, error: result.error }; +} + +export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> { + const cookieStore = await cookies(); + const headerStore = await headers(); + const cookieHeader = headerStore.get("cookie") || ""; + const rcAuthUid = cookieHeader.split(";").map(c => c.trim()) + .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; + + if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") { + const service = getServiceClient(); + const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId); + return { success: !error, error: error?.message ?? null }; + } + + // Production path — use service role via direct update + const service = getServiceClient(); + const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId); + return { success: !error, error: error?.message ?? null }; +} + +export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> { + const { error } = await publicSupabase.auth.resetPasswordForEmail(email, { + redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`, + }); + return { success: !error, error: error?.message ?? null }; +} + +export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> { + if (useMockData) { + const brands = mockBrands.map(b => ({ id: b.id, name: b.name })); + return { brands, error: null }; + } + + const { data, error } = await publicSupabase.from("brands").select("id, name").order("name"); + return { brands: data ?? [], error: error?.message ?? null }; +} \ No newline at end of file diff --git a/src/actions/ai-import.ts b/src/actions/ai-import.ts new file mode 100644 index 0000000..21cd0c9 --- /dev/null +++ b/src/actions/ai-import.ts @@ -0,0 +1,459 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser"; +import { importProductsBatch } from "@/actions/import-products"; +import { importOrdersBatch } from "@/actions/import-orders"; +import { createStopsBatch } from "@/actions/stops"; +import { importContactsBatch } from "@/actions/communications/contacts"; + +export type ImportEntityType = "products" | "orders" | "contacts" | "stops" | "unknown"; + +export type ColumnMapping = Record; // header → semantic field + +export type ImportAnalysis = { + detectedType: ImportEntityType; + confidence: number; + columnMappings: ColumnMapping; + cleanedRows: Record[]; + rawRows: string[][]; + headers: string[]; + rowCount: number; + warnings: string[]; + autoFixApplied: string[]; +}; + +export type AnalyzeImportResult = + | { success: true; analysis: ImportAnalysis } + | { success: false; error: string }; + +export type ExecuteImportResult = + | { success: true; created: number; updated: number; errors: { row: number; message: string }[] } + | { success: true; imported: number; errors: { row: number; message: string }[] } + | { success: false; error: string }; + +// ── Analyze ───────────────────────────────────────────────────────────────── + +export async function analyzeImport( + base64Data: string, + fileName: string, + brandId: string +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + return { success: false, error: "Not authorized for this brand" }; + } + + // Decode file + let rawText: string; + try { + const binaryStr = atob(base64Data); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); + rawText = new TextDecoder("utf-8", { fatal: false }).decode(bytes); + } catch { + return { success: false, error: "Could not decode file" }; + } + + // Parse based on file type + let headers: string[]; + let rows: string[][]; + + const ext = fileName.split(".").pop()?.toLowerCase() ?? ""; + if (["xlsx", "xls"].includes(ext)) { + const binaryStr = atob(base64Data); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); + const buf = Buffer.from(bytes); + const parsed = await parseExcelBuffer(buf); + headers = parsed.headers; + rows = parsed.rows; + } else { + const parsed = parseTextBuffer(rawText); + headers = parsed.headers; + rows = parsed.rows; + } + + if (headers.length === 0 || rows.length === 0) { + return { success: false, error: "File appears to be empty" }; + } + + if (rows.length > 5000) { + return { success: false, error: "File too large. Max 5,000 rows." }; + } + + // Call AI to analyze + const analysis = await callAIAnalysis(headers, rows, brandId); + return { success: true, analysis }; +} + +// ── Execute ────────────────────────────────────────────────────────────────── + +export async function executeImport( + brandId: string, + detectedType: ImportEntityType, + rows: Record[] +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + switch (detectedType) { + case "products": + return executeProductsImport(brandId, rows); + case "orders": + return executeOrdersImport(brandId, rows); + case "stops": + return executeStopsImport(brandId, rows); + case "contacts": + return executeContactsImport(brandId, rows); + default: + return { success: false, error: `Import type "${detectedType}" not yet supported in Import Center` }; + } +} + +// ── AI Core ────────────────────────────────────────────────────────────────── + +async function callAIAnalysis( + headers: string[], + rows: string[][], + brandId: string +): Promise { + const apiKey = process.env.OPENAI_API_KEY; + + // Build sample rows (first 30 for token economy) + const sampleRows = rows.slice(0, 30); + const sampleText = [headers.join(","), ...sampleRows.map((r) => r.join(","))].join("\n"); + + // Non-AI fallback parser for comparison + const { columnMappings: fallbackMappings, cleanedRows: fallbackRows, warnings } = fallbackParse(headers, rows); + + if (!apiKey) { + // No AI key — use rule-based fallback + return { + detectedType: fallbackMappings["__entity_type"] as ImportEntityType ?? "unknown", + confidence: 0.5, + columnMappings: fallbackMappings, + cleanedRows: fallbackRows, + rawRows: rows, + headers, + rowCount: rows.length, + warnings, + autoFixApplied: [], + }; + } + + // Compose AI prompt + const systemPrompt = `You are a data import analyst for a B2B produce wholesale platform called Route Commerce. +Given CSV-like data with headers and sample rows, respond ONLY with valid JSON (no markdown, no explanation): +{ + "detectedType": "products" | "orders" | "contacts" | "stops" | "unknown", + "confidence": 0.0-1.0, + "columnMappings": { "Header Name": "semantic_field", ... }, + "cleanedRows": [ { "field": "value", ... }, ... ], + "warnings": ["row N: issue description", ...], + "autoFixApplied": ["description of fixes applied", ...] +} + +Semantic fields: +- products: product_name, price, description, product_type (Pickup|Shipping|Pickup & Shipping), active (true|false), image_url +- orders: customer_name, customer_email, customer_phone, stop_id, product_name (or product_id), quantity, fulfillment (Pickup|Shipping) +- contacts: email, phone, first_name, last_name, full_name, tags, email_opt_in (true|false), sms_opt_in (true|false), external_id +- stops: city, state, location (business name or address), date (YYYY-MM-DD), time (HH:MM), address, zip, notes + +Rules: +- Normalize phone numbers to (XXX) XXX-XXXX format +- Normalize email to lowercase +- Trim whitespace from all values +- For prices: keep as number string, flag non-numeric +- For dates: normalize to YYYY-MM-DD +- Set confidence 0.9+ only if clear match +- Map columns by HEADER NAME (exact key in columnMappings), not by position +- cleanedRows: return max 50 sample rows — full import uses the same mapping +- If ambiguous columns exist (e.g. two "email" candidates), pick the one with more populated values +- Do NOT add __entity_type to cleanedRows — only in columnMappings output`; + + try { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: `Headers: ${headers.join(", ")}\n\nData (first 30 rows):\n${sampleText.slice(0, 6000)}` }, + ], + response_format: { type: "json_object" }, + temperature: 0.1, + }), + }); + + if (!res.ok) throw new Error(`OpenAI error: ${res.status}`); + + const data = await res.json(); + const parsed = JSON.parse(data.choices[0].message.content as string); + + return { + detectedType: parsed.detectedType ?? "unknown", + confidence: parsed.confidence ?? 0.5, + columnMappings: parsed.columnMappings ?? {}, + cleanedRows: parsed.cleanedRows ?? fallbackRows, + rawRows: rows, + headers, + rowCount: rows.length, + warnings: parsed.warnings ?? [], + autoFixApplied: parsed.autoFixApplied ?? [], + }; + } catch (err) { + // Fall back to rule-based parsing + return { + detectedType: fallbackMappings["__entity_type"] as ImportEntityType ?? "unknown", + confidence: 0.4, + columnMappings: fallbackMappings, + cleanedRows: fallbackRows, + rawRows: rows, + headers, + rowCount: rows.length, + warnings: [`AI parse failed, using rule-based fallback: ${String(err)}`], + autoFixApplied: [], + }; + } +} + +// ── Rule-Based Fallback Parser ──────────────────────────────────────────────── + +function fallbackParse( + headers: string[], + rows: string[][] +): { columnMappings: ColumnMapping; cleanedRows: Record[]; warnings: string[] } { + const h = headers.map((h) => h.toLowerCase().trim()); + + // Score each header for each entity type + const typeScores: Record = { products: 0, orders: 0, contacts: 0, stops: 0 }; + + const productKeywords = ["name", "product", "price", "cost", "description", "type", "active", "image"]; + const orderKeywords = ["customer", "email", "phone", "order", "stop", "product", "quantity", "fulfillment", "item"]; + const contactKeywords = ["contact", "first", "last", "name", "email", "phone", "opt", "sms", "tag", "external"]; + const stopKeywords = ["city", "state", "location", "address", "date", "time", "pickup", "stop", "zip", "notes"]; + + const keywordMaps: Record = { + products: productKeywords, + orders: orderKeywords, + contacts: contactKeywords, + stops: stopKeywords, + }; + + for (const header of h) { + for (const [etype, keywords] of Object.entries(keywordMaps)) { + for (const kw of keywords) { + if (header.includes(kw)) typeScores[etype] = (typeScores[etype] ?? 0) + 1; + } + } + } + + const detectedType = (Object.entries(typeScores).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown") as ImportEntityType; + + // Map headers to semantic fields + const columnMappings: ColumnMapping = {}; + const semanticMap: Record = { + product_name: ["product_name", "name", "product", "item", "goods_name", "item_name", "item name", "title", "product name", "merchandise"], + price: ["price", "cost", "retail_price", "unit_price", "sale_price", "amount", "product_price"], + description: ["description", "desc", "product_desc", "long_description", "details", "product_description"], + product_type: ["product_type", "type", "category", "product_category", "fulfillment_type", "fulfillment", "shipping_type", "delivery_type"], + active: ["active", "available", "in_stock", "is_active", "enabled", "published", "is_available"], + image_url: ["image_url", "image", "photo", "picture", "img_url", "product_image", "pic"], + customer_name: ["customer_name", "name", "customer", "buyer_name", "ordered_by", "purchaser", "buyer"], + customer_email: ["customer_email", "email", "buyer_email", "e-mail", "email_address", "customer e-mail"], + customer_phone: ["customer_phone", "phone", "telephone", "mobile", "cell", "phone_number", "contact", "tel"], + stop_id: ["stop_id", "stop", "pickup_location", "location_id", "stop id", "route_id", "schedule_id"], + quantity: ["quantity", "qty", "amount", "count", "units", "number_of_items", "item_count"], + fulfillment: ["fulfillment", "fulfillment_type", "delivery_method", "ship_or_pickup", "shipping_method", "fulfill", "delivery", "shipping"], + product_id: ["product_id", "item_id", "sku", "item_number", "product id", "item number"], + first_name: ["first_name", "first", "firstName", "fname"], + last_name: ["last_name", "last", "lastName", "lname"], + full_name: ["full_name", "name", "customer_name"], + tags: ["tags", "tag", "segments", "segment", "labels"], + email_opt_in: ["email_opt_in", "opt_in_email", "email_opt", "emailoptin"], + sms_opt_in: ["sms_opt_in", "opt_in_sms", "sms_opt", "smsoptin"], + external_id: ["external_id", "externalid", "id", "reference", "external_id"], + city: ["city", "town", "locality"], + state: ["state", "st", "province"], + location: ["location", "pickup_location", "venue", "place", "business", "name", "location_name"], + date: ["date", "pickup_date", "delivery_date", "stop_date", "date_of_stop"], + time: ["time", "pickup_time", "start_time", "window", "hours", "pickup_hours"], + address: ["address", "street_address", "street", "addr"], + zip: ["zip", "zipcode", "postal_code", "postal", "zip_code"], + notes: ["notes", "note", "special_instructions", "comments", "memo", "instruction"], + }; + + for (let i = 0; i < h.length; i++) { + const header = h[i]; + for (const [field, keywords] of Object.entries(semanticMap)) { + if (keywords.some((kw) => header.includes(kw))) { + columnMappings[headers[i]] = field; + } + } + } + + columnMappings["__entity_type"] = detectedType; + + // Build cleaned rows + const cleanedRows = rows.map((row) => { + const obj: Record = {}; + for (let i = 0; i < row.length; i++) { + const header = headers[i]; + const field = columnMappings[header]; + if (field && field !== "__entity_type") { + let val = row[i]?.trim() ?? ""; + // Normalize + if (field === "price") val = val.replace(/[^0-9.]/g, ""); + if (field === "email_opt_in" || field === "sms_opt_in") val = val.toLowerCase(); + obj[field] = val; + } + } + return obj; + }); + + return { columnMappings, cleanedRows, warnings: [] }; +} + +// ── Import Executors ───────────────────────────────────────────────────────── + +async function executeProductsImport(brandId: string, rows: Record[]) { + const products = rows.map((r) => ({ + name: String(r.product_name ?? r.name ?? ""), + description: String(r.description ?? ""), + price: parseFloat(String(r.price ?? "0").replace(/[^0-9.]/g, "")) || 0, + type: normalizeFulfillmentType(String(r.product_type ?? r.type ?? "Pickup")), + active: String(r.active ?? "true").toLowerCase() !== "false", + image_url: r.image_url ? String(r.image_url) : undefined, + })).filter((p) => p.name !== ""); + + const result = await importProductsBatch(brandId, products); + if (!result.success) { + return { success: false, error: result.error } as ExecuteImportResult; + } + return { + success: true as const, + created: result.created, + updated: result.updated, + errors: result.errors.map((e) => ({ row: 0, message: e.error })), + }; +} + +async function executeOrdersImport(brandId: string, rows: Record[]) { + // Group rows by customer+stop (merge multi-item rows) + const orderMap: Record = {}; + + for (const row of rows) { + const key = `${row.customer_email}_${row.stop_id}`; + if (!orderMap[key]) { + orderMap[key] = { + customer_name: String(row.customer_name ?? ""), + customer_email: String(row.customer_email ?? ""), + customer_phone: String(row.customer_phone ?? ""), + stop_id: String(row.stop_id ?? ""), + items: [], + }; + } + if (row.product_id || row.product_name) { + orderMap[key].items.push({ + product_id: String(row.product_id ?? ""), + quantity: parseInt(String(row.quantity ?? "1")) || 1, + fulfillment: normalizeFulfillmentType(String(row.fulfillment ?? "Pickup")), + }); + } + } + + const orders = Object.values(orderMap) + .filter((o) => o.customer_email && o.stop_id) + .map((o) => ({ + customer_name: o.customer_name || "", + customer_email: o.customer_email, + customer_phone: o.customer_phone || "", + stop_id: o.stop_id, + items: o.items, + })); + + return importOrdersBatch(brandId, orders).then((r) => { + if (r.success) { + return { + success: true as const, + imported: r.imported as number, + errors: (r.errors as Array<{ row: number; error: string }>).map((e) => ({ row: e.row ?? 0, message: e.error ?? String(e) })), + }; + } + return { success: false, error: r.error as string }; + }) as Promise; +} + +async function executeStopsImport(brandId: string, rows: Record[]) { + const stops = rows.map((r) => ({ + city: String(r.city ?? ""), + state: String(r.state ?? ""), + location: String(r.location ?? r.address ?? ""), + date: normalizeDate(String(r.date ?? "")), + time: String(r.time ?? ""), + address: r.address ? String(r.address) : undefined, + zip: r.zip ? String(r.zip) : undefined, + notes: r.notes ? String(r.notes) : undefined, + })).filter((s) => s.city !== "" && s.state !== ""); + + return createStopsBatch(brandId, stops).then((r) => { + if (r.success) { + return { success: true as const, created: r.created as number, updated: 0, errors: [] }; + } + return { success: false, error: r.error as string }; + }) as Promise; +} + +async function executeContactsImport(brandId: string, rows: Record[]) { + const contacts = rows.map((r) => ({ + email: r.email ? String(r.email).toLowerCase().trim() : undefined, + phone: r.phone ? String(r.phone).trim() : undefined, + first_name: r.first_name ? String(r.first_name).trim() : undefined, + last_name: r.last_name ? String(r.last_name).trim() : undefined, + full_name: r.full_name ? String(r.full_name).trim() : undefined, + tags: r.tags ? String(r.tags).split(",").map((t: string) => t.trim()) : [], + email_opt_in: r.email_opt_in !== undefined ? String(r.email_opt_in).toLowerCase() === "true" : true, + sms_opt_in: r.sms_opt_in !== undefined ? String(r.sms_opt_in).toLowerCase() === "true" : false, + external_id: r.external_id ? String(r.external_id).trim() : undefined, + })).filter((c) => c.email || c.phone); + + const result = await importContactsBatch({ brandId, contacts }); + if (!result.success) { + return { success: false, error: result.error } as ExecuteImportResult; + } + return { + success: true as const, + created: result.result.created, + updated: result.result.updated, + errors: result.result.errors.map((e) => ({ row: 0, message: e.error ?? "Unknown error" })), + }; +} + +// ── Normalizers ────────────────────────────────────────────────────────────── + +function normalizeFulfillmentType(t: string): string { + const lower = t.toLowerCase().replace(/[^a-z]/g, ""); + if (lower.includes("pickup") && lower.includes("ship")) return "Pickup & Shipping"; + if (lower.includes("ship")) return "Shipping"; + return "Pickup"; +} + +function normalizeDate(dateStr: string): string { + if (!dateStr) return ""; + // Try common formats + const d = new Date(dateStr); + if (!isNaN(d.getTime())) return d.toISOString().split("T")[0]; + // Try MM/DD/YYYY + const parts = dateStr.split(/[\/\-]/); + if (parts.length === 3) { + const [, m, d2] = parts; + if (m && d2) { + const normalized = new Date(`${parts[2]}-${m.padStart(2, "0")}-${d2.padStart(2, "0")}`); + if (!isNaN(normalized.getTime())) return normalized.toISOString().split("T")[0]; + } + } + return dateStr; +} \ No newline at end of file diff --git a/src/actions/audit.ts b/src/actions/audit.ts new file mode 100644 index 0000000..85ca00f --- /dev/null +++ b/src/actions/audit.ts @@ -0,0 +1,76 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export type AuditAction = "INSERT" | "UPDATE" | "DELETE"; + +export type AuditPayload = { + table_name: string; + record_id: string; + action: AuditAction; + old_data?: Record | null; + new_data?: Record | null; + brand_id?: string | null; +}; + +type AuditResult = + | { success: true; audit_id: string } + | { success: false; error: string }; + +/** + * Logs an audit event to the audit_logs table. + * + * In dev mode (dev_session cookie), uses the dev user identity. + * In production (Supabase auth), resolves the admin user from admin_users. + * + * Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function. + */ +export async function logAuditEvent(payload: AuditPayload): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const adminUser = await getAdminUser(); + + const performed_by = adminUser?.user_id ?? null; + const performed_by_email = + adminUser?.user_id === "dev-user-00000000-0000-0000-0000-000000000000" + ? "dev@platform-admin" + : adminUser?.user_id + ? `admin_user:${adminUser.user_id}` + : null; + + const rpcPayload = { + table_name: payload.table_name, + record_id: payload.record_id, + action: payload.action, + old_data: payload.old_data ?? null, + new_data: payload.new_data ?? null, + performed_by, + performed_by_email, + brand_id: payload.brand_id ?? null, + }; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/log_audit_event`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, + body: JSON.stringify({ p_payload: rpcPayload }), + } + ); + + if (!response.ok) { + const err = await response.json().catch(() => ({ message: "Unknown error" })); + return { success: false, error: err.message ?? "Failed to write audit log" }; + } + + const data = await response.json(); + const auditId = typeof data === "string" ? data : data?.[0]?.id ?? data?.id; + + if (!auditId) { + return { success: false, error: "Audit log written but ID not returned" }; + } + + return { success: true, audit_id: auditId }; +} \ No newline at end of file diff --git a/src/actions/billing/retail-checkout.ts b/src/actions/billing/retail-checkout.ts new file mode 100644 index 0000000..5708122 --- /dev/null +++ b/src/actions/billing/retail-checkout.ts @@ -0,0 +1,62 @@ +"use server"; + +import { svcHeaders } from "@/lib/svc-headers"; + +type LineItem = { + id: string; + name: string; + price: number; + quantity: number; +}; + +export async function createRetailStripeCheckoutSession( + items: LineItem[], + orderId: string, + brandId: string, + successUrl: string, + cancelUrl: string +): Promise<{ success: boolean; url?: string; sessionId?: string; error?: string }> { + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) return { success: false, error: "Stripe not configured on this server." }; + + const Stripe = (await import("stripe")).default; + const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + + const lineItems = items.map((item) => ({ + price_data: { + currency: "usd", + product_data: { name: item.name }, + unit_amount: Math.round(item.price * 100), // stripe uses cents + }, + quantity: item.quantity, + })); + + // Get brand name for Stripe metadata + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + let brandName = "Route Commerce"; + try { + const brandRes = await fetch( + `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, + { headers: { ...svcHeaders(supabaseKey) } } + ); + const brands = await brandRes.json() as Array<{ name: string }>; + if (brands?.[0]?.name) brandName = brands[0].name; + } catch { + // use default + } + + const session = await stripe.checkout.sessions.create({ + mode: "payment", + line_items: lineItems, + success_url: successUrl, + cancel_url: cancelUrl, + metadata: { + order_id: orderId, + brand_id: brandId, + brand_name: brandName, + }, + }); + + return { success: true, url: session.url ?? undefined, sessionId: session.id }; +} \ No newline at end of file diff --git a/src/actions/billing/stripe-checkout.ts b/src/actions/billing/stripe-checkout.ts new file mode 100644 index 0000000..7f5422c --- /dev/null +++ b/src/actions/billing/stripe-checkout.ts @@ -0,0 +1,172 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +// ── Price ID config ──────────────────────────────────────────────────────────── +// Maps plan/addon keys to Stripe price IDs via environment variables + +const PRICE_KEYS: Record = { + starter: process.env.STRIPE_PRICE_STARTER, + farm: process.env.STRIPE_PRICE_FARM, + enterprise: process.env.STRIPE_PRICE_ENTERPRISE, + harvest_reach: process.env.STRIPE_PRICE_HARVEST_REACH, + wholesale_portal: process.env.STRIPE_PRICE_WHOLESALE_PORTAL, + water_log: process.env.STRIPE_PRICE_WATER_LOG, + ai_tools: process.env.STRIPE_PRICE_AI_TOOLS, + square_sync: process.env.STRIPE_PRICE_SQUARE_SYNC, + sms_campaigns: process.env.STRIPE_PRICE_SMS_CAMPAIGNS, +}; + +function getPriceId(key: string): string | null { + return PRICE_KEYS[key] ?? null; +} + +// ── Checkout session creation ───────────────────────────────────────────────── + +export async function createStripeCheckoutSession( + brandId: string, + priceKey: string, + successPath: string, + cancelPath: string, + annual = false +): Promise<{ success: boolean; url?: string; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) return { success: false, error: "Stripe not configured" }; + + const priceId = getPriceId(priceKey); + if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + // Get brand's Stripe customer ID + const custRes = await fetch( + `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`, + { headers: { ...svcHeaders(supabaseKey) } } + ); + const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>; + const brand = brands[0]; + if (!brand?.stripe_customer_id) { + return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." }; + } + + const Stripe = (await import("stripe")).default; + const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; + + // Build recurring interval + const recurring = annual ? { interval: "year" } : { interval: "month" }; + + const session = await stripe.checkout.sessions.create({ + customer: brand.stripe_customer_id!, + mode: "subscription", + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${siteUrl}${successPath}?session_id={CHECKOUT_SESSION_ID}&status=success`, + cancel_url: `${siteUrl}${cancelPath}?status=cancelled`, + metadata: { brand_id: brandId, price_key: priceKey, billing: annual ? "annual" : "monthly" }, + subscription_data: { + metadata: { brand_id: brandId, price_key: priceKey }, + }, + }); + + return { success: true, url: session.url ?? undefined }; +} + +export async function createPlanUpgradeCheckout( + brandId: string, + planTier: string +): Promise<{ success: boolean; url?: string; error?: string }> { + if (!["starter", "farm", "enterprise"].includes(planTier)) { + return { success: false, error: "Invalid plan tier" }; + } + return createStripeCheckoutSession( + brandId, + planTier, + "/admin/settings/billing", + "/admin/settings/billing" + ); +} + +export async function createAddonCheckoutSession( + brandId: string, + addonKey: string +): Promise<{ success: boolean; url?: string; error?: string }> { + return createStripeCheckoutSession( + brandId, + addonKey, + "/admin/settings/billing", + "/admin/settings/billing" + ); +} + +export async function cancelAddonSubscription( + brandId: string, + addonKey: string +): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) return { success: false, error: "Stripe not configured" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + // Get active subscription for this brand + const subRes = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_brand_subscription`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId }), + } + ); + if (!subRes.ok) return { success: false, error: "Failed to get subscription" }; + const subData = await subRes.json() as { stripe_subscription_id?: string }; + if (!subData?.stripe_subscription_id) { + return { success: false, error: "No active subscription found" }; + } + + const Stripe = (await import("stripe")).default; + const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + + // Retrieve subscription and find the item for this add-on + const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id); + const targetPriceId = getPriceId(addonKey); + + const itemToDelete = subscription.items.data.find( + (item) => item.price.id === targetPriceId + ); + + if (!itemToDelete) { + return { success: false, error: "Add-on subscription item not found in Stripe" }; + } + + await stripe.subscriptions.update(subData.stripe_subscription_id, { + items: [{ id: itemToDelete.id, deleted: true }], + proration_behavior: "none", + }); + + // Disable the feature flag locally + await fetch( + `${supabaseUrl}/rest/v1/rpc/set_brand_feature`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }), + } + ); + + return { success: true }; +} \ No newline at end of file diff --git a/src/actions/billing/stripe-portal.ts b/src/actions/billing/stripe-portal.ts new file mode 100644 index 0000000..7277ff0 --- /dev/null +++ b/src/actions/billing/stripe-portal.ts @@ -0,0 +1,147 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) return { success: false, error: "Stripe not configured" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + // Get stripe_customer_id from brands table + const custRes = await fetch( + `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id`, + { headers: svcHeaders(supabaseKey) } + ); + const custData = await custRes.json(); + const stripeCustomerId = custData?.[0]?.stripe_customer_id; + + if (!stripeCustomerId) { + return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." }; + } + + const Stripe = (await import("stripe")).default; + const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + + const session = await stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing`, + }); + + return { success: true, url: session.url }; +} + +export async function updateBrandPlanTier(brandId: string, planTier: string): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + if (!["starter", "farm", "enterprise"].includes(planTier)) { + return { success: false, error: "Invalid plan tier" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }), + } + ); + + if (!res.ok) return { success: false, error: "Failed to update plan tier" }; + return { success: true }; +} + +export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId, p_stripe_customer_id: stripeCustomerId }), + } + ); + + if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" }; + return { success: true }; +} + +export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId }), + } + ); + + if (!res.ok) return { success: false, error: "Failed to fetch plan info" }; + const data = await res.json(); + if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" }; + return { success: true, data }; +} + +export async function getEnabledAddons(brandId: string): Promise> { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_brand_features`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId }), + } + ); + + // get_brand_features returns JSONB — a single object, not an array + if (!res.ok) return {}; + const data = await res.json(); + if (typeof data !== "object" || data === null) return {}; + return data as Record; +} + +export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const res = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId }), + } + ); + + if (!res.ok) return []; + const data = await res.json(); + if (!Array.isArray(data)) return []; + return data.slice(0, limit); +} \ No newline at end of file diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts new file mode 100644 index 0000000..939af8e --- /dev/null +++ b/src/actions/brand-settings.ts @@ -0,0 +1,396 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export type UploadLogoResult = + | { success: true; logoUrl: string } + | { success: false; error: string }; + +export async function uploadBrandLogo( + brandId: string, + file: File, + isDark: boolean = false +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + return { success: false, error: "Not authorized for this brand" }; + } + + const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"]; + if (!validTypes.includes(file.type)) { + return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." }; + } + if (file.size > 5 * 1024 * 1024) { + return { success: false, error: "File too large. Max 5MB." }; + } + + const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; + const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`; + const storagePath = `brand-logos/${brandId}/${path}`; + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const uploadRes = await fetch( + `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, + { + method: "PUT", + headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, + body: buffer, + } + ); + + if (!uploadRes.ok) { + return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + } + + const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; + + // Save URL to brand_settings via RPC + const saveRes = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: brandId, + [isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl, + }), + } + ); + + if (!saveRes.ok) { + return { success: false, error: "Upload succeeded but failed to save URL" }; + } + + return { success: true, logoUrl: publicUrl }; +} + +export async function uploadOlatheSweetLogo( + brandId: string, + file: File +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + return { success: false, error: "Not authorized for this brand" }; + } + + const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"]; + if (!validTypes.includes(file.type)) { + return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." }; + } + if (file.size > 5 * 1024 * 1024) { + return { success: false, error: "File too large. Max 5MB." }; + } + + const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; + const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`; + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const uploadRes = await fetch( + `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, + { + method: "PUT", + headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, + body: buffer, + } + ); + + if (!uploadRes.ok) { + return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + } + + const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; + + const saveRes = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: brandId, + p_olathe_sweet_logo_url: publicUrl, + }), + } + ); + + if (!saveRes.ok) { + return { success: false, error: "Upload succeeded but failed to save URL" }; + } + + return { success: true, logoUrl: publicUrl }; +} + +export async function uploadOlatheSweetLogoDark( + brandId: string, + file: File +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + return { success: false, error: "Not authorized for this brand" }; + } + + const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"]; + if (!validTypes.includes(file.type)) { + return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." }; + } + if (file.size > 5 * 1024 * 1024) { + return { success: false, error: "File too large. Max 5MB." }; + } + + const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1]; + const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`; + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const uploadRes = await fetch( + `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, + { + method: "PUT", + headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, + body: buffer, + } + ); + + if (!uploadRes.ok) { + return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; + } + + const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; + + const saveRes = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: brandId, + p_olathe_sweet_logo_url_dark: publicUrl, + }), + } + ); + + if (!saveRes.ok) { + return { success: false, error: "Upload succeeded but failed to save URL" }; + } + + return { success: true, logoUrl: publicUrl }; +} + +export type BrandSettings = { + id?: string; + brand_id: string; + brand_name: string; + legal_business_name: string | null; + phone: string | null; + email: string | null; + website_url: string | null; + street_address: string | null; + city: string | null; + state: string | null; + postal_code: string | null; + country: string | null; + logo_url: string | null; + logo_url_dark: string | null; + olathe_sweet_logo_url: string | null; + olathe_sweet_logo_url_dark: string | null; + default_email_signature: string | null; + invoice_footer_notes: string | null; + // Storefront customization fields + hero_tagline: string | null; + about_headline: string | null; + about_subheadline: string | null; + custom_footer_text: string | null; + show_wholesale_link: boolean | null; + show_zip_search: boolean | null; + show_schedule_pdf: boolean | null; + show_text_alerts: boolean | null; + schedule_pdf_notes: string | null; + hero_image_url: string | null; + // Color customization + brand_primary_color: string | null; + brand_secondary_color: string | null; + brand_bg_color: string | null; + brand_text_color: string | null; + // Tax settings + collect_sales_tax: boolean | null; + nexus_states: string[] | null; + updated_at?: string; +}; + +export type GetBrandSettingsResult = + | { success: true; settings: BrandSettings | null } + | { success: false; error: string }; + +export type SaveBrandSettingsResult = + | { success: true; settings: BrandSettings } + | { success: false; error: string }; + +export async function getBrandSettings(brandId: string): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_brand_settings`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to fetch brand settings" }; + const data = await response.json(); + return { success: true, settings: data }; +} + +// Public version for storefront pages — uses slug, no auth required +export async function getBrandSettingsPublic(brandSlug: string): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_slug: brandSlug }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; + const data = await response.json(); + return { + success: true, + settings: data, + wholesaleEnabled: data?.wholesale_enabled, + }; +} + +export async function saveBrandSettings(params: { + brandId: string; + legalBusinessName?: string; + phone?: string; + email?: string; + websiteUrl?: string; + streetAddress?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + logoUrl?: string; + logoUrlDark?: string; + olatheSweetLogoUrl?: string; + olatheSweetLogoUrlDark?: string; + defaultEmailSignature?: string; + invoiceFooterNotes?: string; + // Storefront customization fields + heroTagline?: string; + aboutHeadline?: string; + aboutSubheadline?: string; + customFooterText?: string; + showWholesaleLink?: boolean; + showZipSearch?: boolean; + showSchedulePdf?: boolean; + showTextAlerts?: boolean; + schedulePdfNotes?: string; + heroImageUrl?: string; + brandPrimaryColor?: string; + brandSecondaryColor?: string; + brandBgColor?: string; + brandTextColor?: string; + collectSalesTax?: boolean; + nexusStates?: string[]; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") { + return { success: false, error: "Not authorized" }; + } + + if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) { + return { success: false, error: "Not authorized for this brand" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, + { + method: "POST", + headers: { + ...svcHeaders(supabaseKey), + "Content-Type": "application/json", + Prefer: "return=representation", + }, + body: JSON.stringify({ + p_brand_id: params.brandId, + p_legal_business_name: params.legalBusinessName ?? null, + p_phone: params.phone ?? null, + p_email: params.email ?? null, + p_website_url: params.websiteUrl ?? null, + p_street_address: params.streetAddress ?? null, + p_city: params.city ?? null, + p_state: params.state ?? null, + p_postal_code: params.postalCode ?? null, + p_country: params.country ?? null, + p_logo_url: params.logoUrl ?? null, + p_logo_url_dark: params.logoUrlDark ?? null, + p_olathe_sweet_logo_url: params.olatheSweetLogoUrl ?? null, + p_olathe_sweet_logo_url_dark: params.olatheSweetLogoUrlDark ?? null, + p_default_email_signature: params.defaultEmailSignature ?? null, + p_invoice_footer_notes: params.invoiceFooterNotes ?? null, + p_hero_tagline: params.heroTagline ?? null, + p_about_headline: params.aboutHeadline ?? null, + p_about_subheadline: params.aboutSubheadline ?? null, + p_custom_footer_text: params.customFooterText ?? null, + p_show_wholesale_link: params.showWholesaleLink ?? null, + p_show_zip_search: params.showZipSearch ?? null, + p_show_schedule_pdf: params.showSchedulePdf ?? null, + p_show_text_alerts: params.showTextAlerts ?? null, + p_schedule_pdf_notes: params.schedulePdfNotes ?? null, + p_hero_image_url: params.heroImageUrl ?? null, + p_brand_primary_color: params.brandPrimaryColor ?? null, + p_brand_secondary_color: params.brandSecondaryColor ?? null, + p_brand_bg_color: params.brandBgColor ?? null, + p_brand_text_color: params.brandTextColor ?? null, + p_collect_sales_tax: params.collectSalesTax ?? null, + p_nexus_states: params.nexusStates ?? null, + }), + } + ); + + if (!response.ok) { + const err = await response.text(); + return { success: false, error: `Failed to save: ${err.slice(0, 200)}` }; + } + + const data = await response.json(); + return { success: true, settings: data }; +} \ No newline at end of file diff --git a/src/actions/checkout.ts b/src/actions/checkout.ts new file mode 100644 index 0000000..fbf0d1b --- /dev/null +++ b/src/actions/checkout.ts @@ -0,0 +1,261 @@ +"use server"; + +import { svcHeaders } from "@/lib/svc-headers"; + +export type CartItem = { + id: string; + name: string; + price: string; + quantity: number; + fulfillment: "pickup" | "ship"; + brand_id: string; + brand_slug: string; +}; + +type CheckoutItem = { + id: string; + name: string; + price: number; + quantity: number; + fulfillment: "pickup" | "ship"; + is_taxable?: boolean; +}; + +type CreatedOrder = { + id: string; + customer_name: string; + customer_email: string; + customer_phone: string; + subtotal: number; + status: string; + stop_id: string | null; + brand_id: string; + stop_city: string; + stop_state: string; + stop_date: string; + stop_time: string | null; + stop_location: string; + items: { + product_id: string; + product_name: string; + quantity: number; + price: number; + fulfillment: string; + }[]; +}; + +type CheckoutResult = + | { success: true; order: CreatedOrder } + | { success: false; error: string }; + +type ShippingAddress = { + state: string; + postal_code: string; + city?: string; +}; + +export async function createOrder( + idempotencyKey: string, + customerName: string, + customerEmail: string, + customerPhone: string, + stopId: string | null, + items: CheckoutItem[], + brandId?: string, + shippingAddress?: ShippingAddress +): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + // ── Calculate tax if brand collects tax ───────────────────────────────── + let taxAmount = 0; + let taxRate = 0; + let taxLocation = ""; + + if (brandId && shippingAddress) { + try { + const { calculateOrderTax } = await import("@/actions/tax"); + const taxResult = await calculateOrderTax({ + brandId, + subtotal: items.reduce((sum, i) => sum + i.price * i.quantity, 0), + items: items.map((i) => ({ id: i.id, quantity: i.quantity, price: i.price, is_taxable: i.is_taxable })), + fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup", + shippingAddress, + }); + taxAmount = taxResult.taxAmount; + taxRate = taxResult.taxRate; + taxLocation = taxResult.taxLocation; + } catch { + // Tax calculation failure should not block checkout + } + } + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/create_order_with_items`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, + body: JSON.stringify({ + p_idempotency_key: idempotencyKey, + p_customer_name: customerName, + p_customer_email: customerEmail, + p_customer_phone: customerPhone, + p_stop_id: stopId, + p_items: items, + p_tax_amount: taxAmount, + p_tax_rate: taxRate, + p_tax_location: taxLocation || null, + }), + } + ); + + if (!response.ok) { + const err = await response.json().catch(() => ({ message: "Unknown error" })); + return { success: false, error: err.message ?? "Failed to create order" }; + } + + const data = await response.json(); + + // RPC returns a JSONB object with order + items + if (!data || !data.id) { + return { success: false, error: "Order created but data not returned" }; + } + + // Send order receipt email + try { + const { sendOrderReceiptEmail } = await import("@/lib/email-service"); + await sendOrderReceiptEmail({ + customerName, + customerEmail, + orderId: data.id, + items: data.items ?? [], + subtotal: data.subtotal ?? 0, + taxAmount: data.tax_amount ?? 0, + total: (data.subtotal ?? 0) + (data.tax_amount ?? 0), + fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup", + stopCity: data.stop_city ?? undefined, + stopState: data.stop_state ?? undefined, + stopDate: data.stop_date ?? undefined, + stopTime: data.stop_time ?? undefined, + stopLocation: data.stop_location ?? undefined, + brandName: "Tuxedo Corn", + }); + } catch (e) { + // Email failure should not fail the order + } + + return { success: true, order: data as CreatedOrder }; +} + +// ── Cart Persistence ────────────────────────────────────────────────────────── + +export async function getServerCart(userId: string): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + try { + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_user_cart`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_user_id: userId }), + } + ); + + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +export async function mergeLocalCart( + localCart: CartItem[], + userId: string +): Promise<{ merged: CartItem[] }> { + if (!localCart || localCart.length === 0) return { merged: [] }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + // Fetch server cart + let serverCart: CartItem[] = []; + try { + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_user_cart`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_user_id: userId }), + } + ); + if (response.ok) { + const data = await response.json(); + serverCart = Array.isArray(data) ? data : []; + } + } catch { + // proceed with local cart only + } + + // Merge: server items take precedence for fulfillment conflicts; + // quantities are summed for same product IDs. + const mergedMap = new Map(); + + // Add server items first + for (const item of serverCart) { + mergedMap.set(item.id, { ...item }); + } + + // Overlay local items — sum quantities, prefer server fulfillment + for (const local of localCart) { + const existing = mergedMap.get(local.id); + if (existing) { + mergedMap.set(local.id, { + ...existing, + quantity: existing.quantity + local.quantity, + // Server fulfillment wins if set + fulfillment: existing.fulfillment ?? local.fulfillment, + }); + } else { + mergedMap.set(local.id, local); + } + } + + const merged = Array.from(mergedMap.values()); + + // Persist merged cart to server + try { + await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_user_cart`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_user_id: userId, p_items: merged }), + } + ); + } catch { + // best-effort — localStorage is still source of truth for now + } + + return { merged }; +} + +export async function clearServerCart(userId: string): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + try { + await fetch( + `${supabaseUrl}/rest/v1/rpc/clear_user_cart`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_user_id: userId }), + } + ); + } catch { + // ignore + } +} \ No newline at end of file diff --git a/src/actions/communications/campaigns.ts b/src/actions/communications/campaigns.ts new file mode 100644 index 0000000..d752956 --- /dev/null +++ b/src/actions/communications/campaigns.ts @@ -0,0 +1,184 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export type CampaignType = "marketing" | "operational" | "transactional"; +export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled"; + +export type AudienceRules = { + target?: "stop" | "zip_code" | "customer_history" | "product" | "customer_ids" | "all_customers"; + stop_id?: string; + date_from?: string; + date_to?: string; + zip_codes?: string[]; + city?: string; + order_history?: "all" | "first_order" | "repeat"; + days_back?: number; + product_id?: string; + customer_ids?: string[]; +}; + +export type Campaign = { + id: string; + brand_id: string; + name: string; + subject: string | null; + body_text: string | null; + body_html: string | null; + template_id: string | null; + campaign_type: CampaignType; + status: CampaignStatus; + audience_rules: AudienceRules; + scheduled_at: string | null; + sent_at: string | null; + created_by: string | null; + created_at: string; + updated_at: string; +}; + +export type UpsertCampaignResult = { + success: true; + campaign: Campaign; +} | { + success: false; + error: string; +}; + +export type ListCampaignsResult = { + success: true; + campaigns: Campaign[]; +} | { + success: false; + error: string; +}; + +export async function getCommunicationCampaigns(brandId?: string): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: effectiveBrandId }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to fetch campaigns" }; + const data = await response.json(); + return { success: true, campaigns: data?.campaigns ?? [] }; +} + +export async function upsertCampaign(params: { + id?: string; + brand_id: string; + name: string; + subject?: string; + body_text?: string; + body_html?: string; + template_id?: string; + campaign_type: CampaignType; + status?: CampaignStatus; + audience_rules?: AudienceRules; + scheduled_at?: string; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + // Brand scoping: brand_admin can only modify their own brand's campaigns + if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) { + return { success: false, error: "Not authorized to operate on this brand's campaigns" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_communication_campaign`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_id: params.id ?? null, + p_brand_id: params.brand_id, + p_name: params.name, + p_subject: params.subject ?? null, + p_body_text: params.body_text ?? null, + p_body_html: params.body_html ?? null, + p_template_id: params.template_id ?? null, + p_campaign_type: params.campaign_type, + p_status: params.status ?? "draft", + p_audience_rules: params.audience_rules ?? {}, + p_scheduled_at: params.scheduled_at ?? null, + p_created_by: adminUser.user_id, + }), + } + ); + + const data = await response.json(); + if (!response.ok || !data?.id) { + return { success: false, error: data?.message ?? "Failed to save campaign" }; + } + + return { success: true, campaign: data }; +} + +export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + // Brand scoping: brand_admin can only delete their own brand's campaigns + if (adminUser.role === "brand_admin" && brandId && adminUser.brand_id !== brandId) { + return { success: false, error: "Not authorized to delete this brand's campaigns" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/delete_communication_campaign`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to delete campaign" }; + return { success: true }; +} + +export async function getCampaignById(campaignId: string, brandId?: string): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_communication_campaign_by_id`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }), + } + ); + + if (!response.ok) return null; + const data = await response.json(); + const campaign = data?.campaign ?? null; + + // Client-side brand validation + if (campaign && adminUser.role === "brand_admin" && campaign.brand_id !== adminUser.brand_id) { + return null; + } + + return campaign; +} \ No newline at end of file diff --git a/src/actions/communications/contacts.ts b/src/actions/communications/contacts.ts new file mode 100644 index 0000000..6bddcec --- /dev/null +++ b/src/actions/communications/contacts.ts @@ -0,0 +1,457 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; +import { parseCSVWithLimits } from "@/lib/csv-parser"; +import { buildImportPreview } from "@/lib/column-detector"; + +export type ContactSource = "order" | "import" | "manual" | "admin"; + +export type Contact = { + id: string; + brand_id: string; + email: string | null; + phone: string | null; + first_name: string | null; + last_name: string | null; + full_name: string | null; + source: ContactSource; + external_id: string | null; + customer_id: string | null; + email_opt_in: boolean; + sms_opt_in: boolean; + email_opt_in_at: string | null; + sms_opt_in_at: string | null; + unsubscribed_at: string | null; + tags: string[]; + metadata: Record; + created_at: string; + updated_at: string; +}; + +export type ContactImportEntry = { + email?: string; + phone?: string; + first_name?: string; + last_name?: string; + full_name?: string; + tags?: string[]; + email_opt_in?: boolean; + sms_opt_in?: boolean; + external_id?: string; + /** Ignored columns from the CSV, stored in metadata on import */ + _metadata?: Record; +}; + +export type ImportResult = { + created: number; + updated: number; + skipped: number; + errors: { row: ContactImportEntry; error: string }[]; +}; + +// ── Import preview types (column detection) ──────────────────────────────── + +export type ImportField = + | "email" + | "phone" + | "first_name" + | "last_name" + | "full_name" + | "tags" + | "email_opt_in" + | "sms_opt_in" + | "external_id" + | null; + +export type ColumnMapping = { + csvColumn: string; + field: ImportField; + sampleValues: string[]; +}; + +export type ImportPreviewResult = { + mappings: ColumnMapping[]; + totalRows: number; + validRows: number; + skippedRows: number; + duplicateRows: number; + skippedReasons: { rowIndex: number; reason: string }[]; + sampleRows: ContactImportEntry[]; + ignoredColumns: string[]; + warnings: string[]; +}; + +export type GetContactsResult = { + success: true; + contacts: Contact[]; + total: number; + limit: number; + offset: number; +} | { + success: false; + error: string; +}; + +export async function getContacts(params: { + brandId: string; + search?: string; + source?: string; + limit?: number; + offset?: number; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if ( + adminUser.role === "brand_admin" && + adminUser.brand_id !== params.brandId + ) { + return { success: false, error: "Not authorized for this brand" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_communication_contacts`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: params.brandId, + p_search: params.search ?? null, + p_source: params.source ?? null, + p_limit: params.limit ?? 100, + p_offset: params.offset ?? 0, + }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to fetch contacts" }; + const data = await response.json(); + + return { + success: true, + contacts: data?.contacts ?? [], + total: data?.total ?? 0, + limit: data?.limit ?? 100, + offset: data?.offset ?? 0, + }; +} + +export type UpsertContactResult = { + success: true; + contact: Contact; +} | { + success: false; + error: string; +}; + +export async function upsertContact(contact: { + id?: string; + brand_id: string; + email?: string; + phone?: string; + first_name?: string; + last_name?: string; + full_name?: string; + source: ContactSource; + external_id?: string; + customer_id?: string; + email_opt_in?: boolean; + sms_opt_in?: boolean; + tags?: string[]; + metadata?: Record; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if ( + adminUser.role === "brand_admin" && + adminUser.brand_id !== contact.brand_id + ) { + return { success: false, error: "Not authorized for this brand" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_communication_contact`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_id: contact.id ?? null, + p_brand_id: contact.brand_id, + p_email: contact.email ?? null, + p_phone: contact.phone ?? null, + p_first_name: contact.first_name ?? null, + p_last_name: contact.last_name ?? null, + p_full_name: contact.full_name ?? null, + p_source: contact.source, + p_external_id: contact.external_id ?? null, + p_customer_id: contact.customer_id ?? null, + p_email_opt_in: contact.email_opt_in ?? null, + p_sms_opt_in: contact.sms_opt_in ?? null, + p_tags: contact.tags ?? null, + p_metadata: contact.metadata ?? null, + }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to upsert contact" }; + const data = await response.json(); + + if (!data) return { success: false, error: "No data returned" }; + return { success: true, contact: data as Contact }; +} + +export type ImportContactsResult = { + success: true; + result: ImportResult; +} | { + success: false; + error: string; +}; + +export async function importContactsBatch(params: { + brandId: string; + contacts: ContactImportEntry[]; + allowOptInOverride?: boolean; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if ( + adminUser.role === "brand_admin" && + adminUser.brand_id !== params.brandId + ) { + return { success: false, error: "Not authorized for this brand" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/import_communication_contacts_batch`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: params.brandId, + p_contacts: params.contacts, + p_allow_opt_in_override: params.allowOptInOverride ?? false, + }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to import contacts" }; + const data = await response.json(); + + return { success: true, result: data as ImportResult }; +} + +export type OptOutResult = { + success: true; +} | { + success: false; + error: string; +}; + +export async function optOutContact(params: { + email: string; + brandId: string; + method: "email" | "sms"; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if ( + adminUser.role === "brand_admin" && + adminUser.brand_id !== params.brandId + ) { + return { success: false, error: "Not authorized for this brand" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/opt_out_contact`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_email: params.email, + p_brand_id: params.brandId, + p_method: params.method, + }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to opt out contact" }; + return { success: true }; +} + +export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + if ( + adminUser.role === "brand_admin" && + brandId && + adminUser.brand_id !== brandId + ) { + return { success: false, error: "Not authorized for this brand" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/delete_communication_contact`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_id: id }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to delete contact" }; + const data = await response.json(); + return { success: data }; +} + +// ── Export preview ──────────────────────────────────────────────────────────── + +export type ExportContactsResult = { + success: true; + csv: string; + filename: string; +} | { + success: false; + error: string; +}; + +function escapeCSVValue(value: unknown): string { + if (value === null || value === undefined) return ""; + const str = String(value); + if (str.includes('"') || str.includes(",") || str.includes("\n") || str.includes("\r")) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; +} + +export async function exportContacts(params: { + brandId: string; + brandSlug?: string; + search?: string; + source?: string; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + const effectiveBrandId = + adminUser.role === "brand_admin" ? adminUser.brand_id! : params.brandId; + + if (!effectiveBrandId) return { success: false, error: "No brand context" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const allContacts: Contact[] = []; + let offset = 0; + const batchSize = 1000; + + while (true) { + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_communication_contacts`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: effectiveBrandId, + p_search: params.search ?? null, + p_source: params.source ?? null, + p_limit: batchSize, + p_offset: offset, + }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to fetch contacts" }; + const data = await response.json(); + const batch: Contact[] = data?.contacts ?? []; + + if (batch.length === 0) break; + allContacts.push(...batch); + if (batch.length < batchSize) break; + offset += batchSize; + } + + const headers = [ + "email", + "phone", + "first_name", + "last_name", + "full_name", + "source", + "email_opt_in", + "sms_opt_in", + "unsubscribed_at", + "tags", + "created_at", + "updated_at", + "customer_id", + "metadata.last_order_id", + "metadata.last_order_at", + "metadata.stop_id", + "metadata.imported_raw", + ]; + + const rows = allContacts.map((c) => [ + escapeCSVValue(c.email), + escapeCSVValue(c.phone), + escapeCSVValue(c.first_name), + escapeCSVValue(c.last_name), + escapeCSVValue(c.full_name), + escapeCSVValue(c.source), + c.email_opt_in ? "TRUE" : "FALSE", + c.sms_opt_in ? "TRUE" : "FALSE", + escapeCSVValue(c.unsubscribed_at), + escapeCSVValue(c.tags?.join(";")), + escapeCSVValue(c.created_at), + escapeCSVValue(c.updated_at), + escapeCSVValue(c.customer_id), + escapeCSVValue((c.metadata as Record)?.last_order_id ?? ""), + escapeCSVValue((c.metadata as Record)?.last_order_at ?? ""), + escapeCSVValue((c.metadata as Record)?.stop_id ?? ""), + escapeCSVValue((c.metadata as Record)?.imported_raw ?? ""), + ]); + + const brandSlug = params.brandSlug ?? "contacts"; + const dateStr = new Date().toISOString().slice(0, 10); + const filename = `${brandSlug}-${dateStr}.csv`; + + const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\r\n"); + + return { success: true, csv, filename }; +} + +// ── Import preview ──────────────────────────────────────────────────────────── + +export async function previewContactImport( + csvText: string +): Promise<{ success: true; preview: ImportPreviewResult } | { success: false; error: string }> { + try { + const { csv, totalRows, warnings } = parseCSVWithLimits(csvText); + + if (warnings.length > 0 && totalRows === 0) { + return { success: false, error: warnings[0] }; + } + + const preview = buildImportPreview(csv.headers, csv.rows); + preview.warnings = warnings; + + return { success: true, preview }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Parse error" }; + } +} \ No newline at end of file diff --git a/src/actions/communications/segments.ts b/src/actions/communications/segments.ts new file mode 100644 index 0000000..db33382 --- /dev/null +++ b/src/actions/communications/segments.ts @@ -0,0 +1,116 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; +import type { AudienceRules } from "./campaigns"; + +export type Segment = { + id: string; + brand_id: string; + name: string; + description: string | null; + rules: AudienceRules; + created_by: string | null; + created_at: string; + updated_at: string; +}; + +export type ListSegmentsResult = + | { success: true; segments: Segment[] } + | { success: false; error: string }; + +export type UpsertSegmentResult = + | { success: true; segment: Segment } + | { success: false; error: string }; + +export async function getCommunicationSegments( + brandId: string +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + return { success: false, error: "Not authorized" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_communication_segments`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to fetch segments" }; + const data = await response.json(); + return { success: true, segments: data?.segments ?? [] }; +} + +export async function upsertSegment(params: { + id?: string; + brand_id: string; + name: string; + description?: string; + rules: AudienceRules; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) { + return { success: false, error: "Not authorized" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_id: params.id ?? null, + p_brand_id: params.brand_id, + p_name: params.name, + p_description: params.description ?? null, + p_rules: params.rules, + p_created_by: adminUser.user_id, + }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to save segment" }; + const data = await response.json(); + return { success: true, segment: data }; +} + +export async function deleteSegment( + segmentId: string, + brandId: string +): Promise<{ success: boolean; error?: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + return { success: false, error: "Not authorized" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/delete_communication_segment`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_segment_id: segmentId, p_brand_id: brandId }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to delete segment" }; + return { success: true }; +} \ No newline at end of file diff --git a/src/actions/communications/send.ts b/src/actions/communications/send.ts new file mode 100644 index 0000000..9fe1c48 --- /dev/null +++ b/src/actions/communications/send.ts @@ -0,0 +1,149 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; +import type { AudienceRules } from "./campaigns"; + +export type AudiencePreviewResult = { + count: number; + sample_customers: { id: string; email: string; name: string }[]; +}; + +export async function previewCampaignAudience( + brandId: string, + audienceRules: AudienceRules +): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + + // Brand scoping: brand_admin can only preview their own brand + if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + return null; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: brandId, + p_audience_rules: audienceRules ?? {}, + }), + } + ); + + if (!response.ok) return null; + const data = await response.json(); + return data as AudiencePreviewResult; +} + +export type MessageLogEntry = { + id: string; + brand_id: string; + campaign_id: string | null; + customer_id: string | null; + customer_email: string | null; + delivery_method: string; + subject: string | null; + body_preview: string | null; + status: string; + sent_at: string | null; + error_message: string | null; + event_type: string | null; + event_id: string | null; + created_at: string; + // Analytics columns (populated by Resend webhook) + delivered_at: string | null; + opened_at: string | null; + clicked_at: string | null; + bounced_at: string | null; + bounce_reason: string | null; +}; + +export type GetMessageLogsResult = { + success: true; + logs: MessageLogEntry[]; +} | { + success: false; + error: string; +}; + +export async function getMessageLogs(params: { + brandId: string; + campaignId?: string; + status?: string; + limit?: number; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + // Brand scoping: brand_admin can only view their own brand's logs + if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) { + return { success: false, error: "Not authorized to view these logs" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_message_logs`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: params.brandId, + p_campaign_id: params.campaignId ?? null, + p_status: params.status ?? null, + p_limit: params.limit ?? 100, + }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to fetch logs" }; + const data = await response.json(); + return { success: true, logs: data?.logs ?? [] }; +} + +export type SendCampaignResult = { + success: true; + messages_logged: number; +} | { + success: false; + error: string; +}; + +export async function sendCampaign(campaignId: string, brandId?: string): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + // Resolve brand from campaign or parameter + const effectiveBrandId = brandId ?? adminUser.brand_id; + + // Brand scoping: brand_admin can only send their own brand's campaigns + if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) { + return { success: false, error: "Not authorized to send this brand's campaigns" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/send_campaign`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: effectiveBrandId }), + } + ); + + const data = await response.json(); + if (!response.ok || !data?.success) { + return { success: false, error: data?.error ?? "Failed to send campaign" }; + } + + return { success: true, messages_logged: data.messages_logged ?? 0 }; +} \ No newline at end of file diff --git a/src/actions/communications/settings.ts b/src/actions/communications/settings.ts new file mode 100644 index 0000000..2626573 --- /dev/null +++ b/src/actions/communications/settings.ts @@ -0,0 +1,85 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export type CommunicationSettings = { + id: string; + brand_id: string; + default_sender_email: string | null; + default_sender_name: string | null; + reply_to_email: string | null; + email_provider: string; + email_footer_html: string | null; + created_at: string; + updated_at: string; +}; + +export type UpsertSettingsResult = { + success: true; + settings: CommunicationSettings; +} | { + success: false; + error: string; +}; + +export async function getCommunicationSettings(brandId: string): Promise { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_communication_settings`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: brandId }), + } + ); + + if (!response.ok) return null; + const data = await response.json(); + return data?.settings ?? null; +} + +export async function upsertCommunicationSettings(params: { + brand_id: string; + sender_email?: string; + sender_name?: string; + reply_to_email?: string; + provider?: string; + footer_html?: string; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + // Brand scoping: brand_admin can only modify their own brand's settings + if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) { + return { success: false, error: "Not authorized to modify this brand's communication settings" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_brand_id: params.brand_id, + p_sender_email: params.sender_email ?? null, + p_sender_name: params.sender_name ?? null, + p_reply_to_email: params.reply_to_email ?? null, + p_provider: params.provider ?? "resend", + p_footer_html: params.footer_html ?? null, + }), + } + ); + + const data = await response.json(); + if (!response.ok || !data?.id) { + return { success: false, error: data?.message ?? "Failed to save settings" }; + } + + return { success: true, settings: data }; +} \ No newline at end of file diff --git a/src/actions/communications/stop-blast.ts b/src/actions/communications/stop-blast.ts new file mode 100644 index 0000000..fc4edcf --- /dev/null +++ b/src/actions/communications/stop-blast.ts @@ -0,0 +1,56 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export type StopBlastResult = + | { success: true; campaign_id: string; messages_logged: number } + | { success: false; error: string }; + +export async function sendStopBlast(params: { + stopId: string; + brandId: string; + channel: "sms" | "email" | "both"; + subject?: string; + body: string; + audience: "all" | "pending" | "picked_up"; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) { + return { success: false, error: "Not authorized" }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/send_stop_blast`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_stop_id: params.stopId, + p_brand_id: params.brandId, + p_channel: params.channel, + p_subject: params.subject ?? null, + p_body: params.body, + p_audience: params.audience, + p_created_by: adminUser.user_id, + }), + } + ); + + if (!response.ok) { + const err = await response.json(); + return { success: false, error: err?.message ?? "Failed to send blast" }; + } + + const data = await response.json(); + return { + success: true, + campaign_id: data.campaign_id, + messages_logged: data.messages_logged, + }; +} diff --git a/src/actions/communications/templates.ts b/src/actions/communications/templates.ts new file mode 100644 index 0000000..0ee0651 --- /dev/null +++ b/src/actions/communications/templates.ts @@ -0,0 +1,130 @@ +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; +import type { AudienceRules } from "./campaigns"; + +export type TemplateType = "email_template" | "sms_template" | "internal_note"; +export type CampaignType = "marketing" | "operational" | "transactional"; + +export type Template = { + id: string; + brand_id: string; + name: string; + subject: string; + body_text: string; + body_html: string | null; + template_type: TemplateType; + campaign_type: CampaignType | null; + created_by: string | null; + created_at: string; + updated_at: string; +}; + +export type UpsertTemplateResult = { + success: true; + template: Template; +} | { + success: false; + error: string; +}; + +export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; + + // Brand scoping: brand_admin can only see their own brand's templates + if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) { + return { success: true, templates: [] }; + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/get_communication_templates`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ p_brand_id: effectiveBrandId }), + } + ); + + if (!response.ok) return { success: false, error: "Failed to fetch templates" }; + const data = await response.json(); + return { success: true, templates: data?.templates ?? [] }; +} + +export async function upsertTemplate(params: { + id?: string; + brand_id: string; + name: string; + subject: string; + body_text: string; + body_html?: string; + template_type: TemplateType; + campaign_type?: CampaignType; +}): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { success: false, error: "Not authenticated" }; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + + const response = await fetch( + `${supabaseUrl}/rest/v1/rpc/upsert_communication_template`, + { + method: "POST", + headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, + body: JSON.stringify({ + p_id: params.id ?? null, + p_brand_id: params.brand_id, + p_name: params.name, + p_subject: params.subject, + p_body_text: params.body_text, + p_body_html: params.body_html ?? null, + p_template_type: params.template_type, + p_campaign_type: params.campaign_type ?? null, + p_created_by: adminUser.user_id, + }), + } + ); + + const data = await response.json(); + if (!response.ok || !data?.id) { + return { success: false, error: data?.message ?? "Failed to save template" }; + } + + return { success: true, template: data }; +} + +export async function getTemplateById(templateId: string): Promise