From 95eab42f4bffafa072d1d2e7bb7f0164be1e6e58 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 25 Jun 2026 20:13:19 -0600 Subject: [PATCH 01/44] docs: refresh stale docs to match current implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation pass — reviewed codebase in full, identified stale references, and aligned docs with actual code state. Changes: - CLAUDE.md, PRODUCTION_SETUP.md, LAUNCH_CHECKLIST.md, src/auth.config.ts: middleware path src/middleware.ts → src/proxy.ts (Next.js 16+ convention) - CLAUDE.md, ENVIRONMENT.md, README.md: dev server port localhost:3000 → localhost:4000 (per package.json dev script 'next dev --webpack -H 0.0.0.0 -p 4000') - PRODUCTION_DEPLOYMENT_CHECKLIST.md: rewrote migration list — actual db/migrations/ contains 10 files (0000–0091), not the Supabase-era 001–092 series. Updated platform version 1.6 → 2.0 and last-updated stamp. - MEMORY.md: refreshed 'Last updated' to 2026-06-25, added current-state header flagging Auth.js v5 wiring as historical, and documented the SSH/Gitea workflow (id_ed25519_crispygoat + no API token → push-hook URL flow for opening PRs). - LAUNCH_CHECKLIST.md: added 'Last updated: 2026-06-25' header and refresh note in footer. - CLAUDE.md: new 'Gitea authentication (SSH)' subsection under 'Canonical Remote' documenting ~/.ssh/id_ed25519_crispygoat, ssh-agent setup, what works (git push) vs what doesn't (REST API without token), and the actual PR-creation workflow (push + open the URL the Gitea hook prints). Verification: - npx tsc --noEmit: pre-existing Stripe API version + test mock errors only (verified by stashing changes and re-running — same error set). - No new errors introduced. --- CLAUDE.md | 40 +++++++++++++++++++++++--- ENVIRONMENT.md | 4 +-- LAUNCH_CHECKLIST.md | 6 ++-- MEMORY.md | 4 ++- PRODUCTION_DEPLOYMENT_CHECKLIST.md | 45 ++++++++++++------------------ PRODUCTION_SETUP.md | 4 +-- README.md | 4 +-- src/auth.config.ts | 2 +- 8 files changed, 68 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6561bb9..3e10ff1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,38 @@ There is exactly one remote — `origin` — pointing to the self-hosted Gitea r Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`). +### Gitea authentication (SSH) + +This workspace has been using Gitea via SSH for all operations. The Gitea instance is self-hosted at `git.crispygoat.com` (web UI: `https://git.crispygoat.com`, API base: `https://git.crispygoat.com/api/v1`). + +**SSH key:** `~/.ssh/id_ed25519_crispygoat` is the dedicated key registered with the Gitea instance (alias `dog` per the Gitea SSH banner). Add it to the agent before any git operation: + +```bash +eval "$(ssh-agent -s)" +ssh-add ~/.ssh/id_ed25519_crispygoat +``` + +The SSH config (`~/.ssh/config`) already aliases the host as `crispygoat` with `AddKeysToAgent yes`, so subsequent shells can just `ssh crispygoat` / `git push origin ...` without re-adding. + +**Verified working commands:** +- `git push origin ` — push (this is the primary workflow) +- `ssh -p 22 git@git.crispygoat.com` — auth check; Gitea returns "successfully authenticated" + "Gitea does not provide shell access" + +**API token:** This environment does **not** have a Gitea personal access token in any known location (no `~/.config/gitea`, no `GITEA_ACCESS_TOKEN` env, no token in `~/.config/gh/hosts.yml`). Do not assume a token is available — auth attempts will return `{"message":"invalid username, password or token"}`. + +**`tea` CLI / `gitea-mcp`:** Both are installed but require a token; `--ssh-agent-key` is recognized by `tea logins add` but the underlying SDK still falls back to requiring a token. Treat these as unavailable until a token is provisioned. + +### Opening pull requests (the workflow that actually works) + +Because no Gitea API token is available in this environment, the PR creation flow is: + +1. Create a feature branch: `git checkout -b docs/-` +2. Commit the changes (no need to commit unrelated files — leave the worktree dirty but `git add` only what you intend to ship) +3. Push: `git push -u origin ` +4. The Gitea push hook prints a "Create a new pull request" URL on stderr, e.g. `https://git.crispygoat.com/tyler/route-commerce/pulls/new/` — open that URL in a browser to file the PR through the web UI. + +If a future environment provides a Gitea token, the `tea pr create` and `curl POST /api/v1/repos/tyler/route-commerce/pulls` flows become available; the auth pattern is `Authorization: token ` on the REST API or `tea logins add -t -u https://git.crispygoat.com` for the CLI. + ## 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. @@ -38,7 +70,7 @@ npx playwright test # Run E2E tests (Playwright) **Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details. -E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config. +E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:4000` for local runs (the dev server binds to port `4000`), or pass `--config` with a local config. --- @@ -51,12 +83,12 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp **Auth flow:** 1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set 2. `getAdminUser()` in `src/lib/admin-permissions.ts` reads the Neon Auth session and looks up `admin_users` by email -3. Middleware (`src/middleware.ts`) guards `/admin/*` routes at the edge level +3. Middleware (`src/proxy.ts`) guards `/admin/*` routes at the edge level **Key files:** - `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset) - `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret) -- `src/middleware.ts` — Edge-level route protection +- `src/proxy.ts` — Edge-level route protection - `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API - `src/app/api/auth/forgot-password/route.ts` — Password reset request API - `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API @@ -300,7 +332,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act | Neon Auth configuration | `src/lib/auth.ts`, `src/auth.config.ts` | | Auth API routes | `src/app/api/auth/sign-in/route.ts`, `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`, `src/app/api/auth/[...nextauth]/route.ts` | | Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` | -| Middleware (route protection) | `src/middleware.ts` | +| Middleware (route protection) | `src/proxy.ts` | | Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) | | Admin pages | `src/app/admin/[module]/page.tsx` | | Admin client components | `src/components/admin/*.tsx` | diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index b0da3d9..5991cc1 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -12,7 +12,7 @@ These are safe to commit and can be used in client bundles. Prefix with `NEXT_PU ### `NEXT_PUBLIC_BASE_URL` The base URL of your deployment. Used for OAuth redirects and webhook URLs. -- **Local:** `http://localhost:3000` +- **Local:** `http://localhost:4000` (dev script binds to port 4000; override via `npm run dev -- -p `) - **Production:** `https://yourdomain.com` ### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` @@ -151,7 +151,7 @@ Controls whether to use Square sandbox or production. | Variable | Local (`.env.local`) | Production (hosting dashboard) | |---|---|---| -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | `https://yourdomain.com` | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:4000` (or whatever port the dev server is on) | `https://yourdomain.com` | | `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` | | `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` | | `SQUARE_ENVIRONMENT` | `sandbox` | `production` | diff --git a/LAUNCH_CHECKLIST.md b/LAUNCH_CHECKLIST.md index ee0104e..7c9bcc7 100644 --- a/LAUNCH_CHECKLIST.md +++ b/LAUNCH_CHECKLIST.md @@ -1,5 +1,7 @@ # Route Commerce Launch Checklist Summary +**Last updated:** 2026-06-25 + ## Overview This document summarizes the complete Launch & Marketing Layer implementation for Route Commerce, a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. @@ -15,7 +17,7 @@ This document summarizes the complete Launch & Marketing Layer implementation fo | Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local | | Database Structure | ✓ Complete | `db/migrations/` applied via `pg` | | Role-Based Access | ✓ Complete | `admin-permissions.ts` system | -| Protected Routes | ✓ Complete | `middleware.ts` | +| Protected Routes | ✓ Complete | `src/proxy.ts` (Next.js 16+ middleware convention) | | Server Actions Pattern | ✓ Complete | `src/actions/*.ts` | ### 2. Launch-Ready Features ✓ @@ -242,4 +244,4 @@ Track these metrics post-launch: --- -*Generated: January 2025* \ No newline at end of file +*Generated: January 2025 · Refreshed: 2026-06-25 (see `CLAUDE.md` for current architecture; deployment is via Vercel hooked to the Gitea `origin` remote — there is no GitHub `origin`)* \ No newline at end of file diff --git a/MEMORY.md b/MEMORY.md index 56883d5..630965f 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -2,7 +2,9 @@ This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. -**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work; full Supabase purge) +**Last updated:** 2026-06-25 (added current-state header; restored "Supabase → Postgres" pivot as the canonical entry point for new readers; documented the SSH-based Gitea workflow) + +> **Current state (read first).** Auth is **Neon Auth (Better Auth)** — see `CLAUDE.md`. The "Auth.js v5 wiring complete — 2026-06-06" entry further down is **historical** (it describes work that was subsequently replaced by the Neon Auth migration; `next-auth` remains in `package.json` as a vestigial dep but is no longer imported from `src/`). The Supabase → Postgres pivot section below is the canonical cutover record. The "GitHub origin" branch notes are also historical — the only remote is the Gitea `origin` (see `CLAUDE.md` "Canonical Remote"). Middleware lives at `src/proxy.ts`, not `src/middleware.ts` (Next.js 16+ convention). Repo operations use **SSH** (`~/.ssh/id_ed25519_crispygoat`) — no Gitea API token is provisioned in this env, so `tea`/`gitea-mcp`/REST API are unavailable for write operations; PRs are opened by `git push` + clicking the URL the Gitea push hook prints. ## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043) diff --git a/PRODUCTION_DEPLOYMENT_CHECKLIST.md b/PRODUCTION_DEPLOYMENT_CHECKLIST.md index 3f49b81..8bb51a7 100644 --- a/PRODUCTION_DEPLOYMENT_CHECKLIST.md +++ b/PRODUCTION_DEPLOYMENT_CHECKLIST.md @@ -1,47 +1,38 @@ # Route Commerce — Production Deployment Checklist -**Platform Version:** 1.6 -**Last Updated:** 2026-05-13 +**Platform Version:** 2.0 +**Last Updated:** 2026-06-25 **Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx --- ## 1. Migration Order -Apply migrations in number order. All numbered `001`–`092` are required. +Apply migrations in number order. The full set lives in `db/migrations/`: ```bash -# Push all migrations (sequential, no Supabase CLI required) -npm run migrate:one 001 -npm run migrate:one 002 -# ... through ... -npm run migrate:one 092 - -# Or push all at once +# Push all migrations (idempotent — `_migrations` table tracks what's applied) npm run migrate ``` -**Key migrations (last 15):** +The current migration set is consolidated (the Supabase-era 001–092 series was collapsed into `0001_init.sql` plus a handful of incremental files). If `db/migrations/` is empty after the Supabase → Postgres migration, see `MEMORY.md` "Direction Pivot" for the cutover notes. + +**Current migrations in `db/migrations/`:** | # | 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 | +| 0000 | `0000_qa_neon_auth_stub.sql` | QA-only stub for `neon_auth` schema (real provisioning via Neon Auth in prod) | +| 0001 | `0001_init.sql` | Core schema: brands, products, orders, stops, customers, communication tables, water log, time tracking, RPCs, indexes, triggers. Fully re-runnable (`IF NOT EXISTS` + trigger guards). | +| 0002 | `0002_admin_password.sql` | Adds `password_hash` column to `admin_users` | +| 0003 | `0003_tour_rpc_functions.sql` | RPCs for tour/stop planning | +| 0040 | `0040_command_center.sql` | Adds command-center dashboard tables | +| 0041 | `0041_command_center_schema_fix.sql` | Schema fixup for command-center | +| 0042 | `0042_drop_command_center.sql` | Drops command-center (rolled back) | +| 0043 | `0043_admin_users_extra_columns.sql` | Adds `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login` columns to `admin_users` | +| 0090 | `0090_water_log_completion.sql` | Completes water-log tables/RPCs | +| 0091 | `0091_dashboard_summary_rpc.sql` | `dashboard_summary` RPC for admin dashboard | -> **Note:** Migration 092 adds Stripe subscription tracking. Migration 091 adds plan tier billing. Apply both before deploying billing changes. +> **Note:** The migration runner (`scripts/migrate.js`) tracks applied files in `_migrations` and skips already-applied files. `0001_init.sql` is fully re-runnable, so it is safe to re-apply on a DB that was initialized outside the runner. Stripe subscription tracking, plan-tier billing, brand settings, etc. are all part of `0001_init.sql` — they were originally separate Supabase-era migrations that were consolidated. --- diff --git a/PRODUCTION_SETUP.md b/PRODUCTION_SETUP.md index 18a7e9c..c7effcc 100644 --- a/PRODUCTION_SETUP.md +++ b/PRODUCTION_SETUP.md @@ -24,7 +24,7 @@ npm run dev NEON_AUTH_COOKIE_SECRET= NEXT_PUBLIC_SITE_URL=https://yourdomain.com ``` -3. Configure middleware in `src/middleware.ts` +3. Configure middleware in `src/proxy.ts` ### 2. Stripe Payments @@ -74,7 +74,7 @@ Ensure environment variables are set before deployment. ## Key Files -- `src/middleware.ts` - Neon Auth (Better Auth) middleware +- `src/proxy.ts` - Neon Auth (Better Auth) middleware - `src/lib/stripe-billing.ts` - Stripe integration - `src/lib/analytics.ts` - PostHog analytics - `src/lib/sentry.ts` - Sentry error tracking diff --git a/README.md b/README.md index 799019b..f415837 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ npm run migrate:one 83 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. +Open [http://localhost:4000](http://localhost:4000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. (The dev script binds to `0.0.0.0:4000`; override with `npm run dev -- -p ` if you need a different port.) ### 5. Dev auth bypass @@ -250,7 +250,7 @@ CRON_SECRET=... # Bearer token to secure cron endpoints (generate ```bash # Run abandoned cart with verbose output -curl -X POST http://localhost:3000/api/email-automation/abandoned-cart \ +curl -X POST http://localhost:4000/api/email-automation/abandoned-cart \ -H "Authorization: Bearer local-dev-secret" \ -H "Content-Type: application/json" diff --git a/src/auth.config.ts b/src/auth.config.ts index 00ddea1..8dd14d7 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -1,7 +1,7 @@ /** * Edge-safe Neon Auth configuration. * - * This file is imported by `src/middleware.ts`, which runs in the Edge + * This file is imported by `src/proxy.ts`, which runs in the Edge * runtime. It must NOT import: * - `pg` / any Node-only database driver * - The Drizzle client From 880c52227a34a65c177ec01dfa6126e6219fe603 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 25 Jun 2026 21:37:21 -0600 Subject: [PATCH 02/44] refactor(wholesale): split 2391-line WholesaleClient into focused modules The Wholesale Portal admin client was the largest single file in the app (2391 lines) and mixed 11 distinct UI concerns in one module. Split into focused files under src/components/wholesale/admin/: types.ts shared types (MsgFn, PendingRegistration, ...) WholesaleIcon.tsx header SVG WholesaleLoadingSkeleton.tsx loading state StatusBadge.tsx order status pill AddRecipientForm.tsx notification recipient input PriceSheetModal.tsx bulk price-sheet send confirmation CustomerPricingPanel.tsx per-customer pricing overrides WebhookSettingsSection.tsx outbound webhook config + test dispatch DashboardTab.tsx stat cards + recent orders + webhook feed ProductsTab.tsx wholesale product CRUD CustomersTab.tsx customers + registrations + pricing OrdersTab.tsx orders + bulk ops + deposit modals SettingsTab.tsx portal settings + webhook + recipients WholesaleClient.tsx is now a 189-line shell that owns data loading and tab routing. No behavior changes; the original logic was preserved verbatim. Mechanical extraction only. Also fixed an existing import bug where getPendingWholesaleRegistrations was being imported from @/actions/wholesale instead of @/actions/wholesale-register (typecheck caught it). Verified: typecheck clean for new code (pre-existing dahlia/fetch mock errors unrelated). Build compiles through to the same pre-existing billing type error. Zero new lint warnings introduced. Refactor-progress tracked at /tmp/refactor-routecomm.md. --- src/app/admin/wholesale/WholesaleClient.tsx | 2256 +---------------- .../wholesale/admin/AddRecipientForm.tsx | 52 + .../wholesale/admin/CustomerPricingPanel.tsx | 124 + .../wholesale/admin/CustomersTab.tsx | 529 ++++ .../wholesale/admin/DashboardTab.tsx | 126 + src/components/wholesale/admin/OrdersTab.tsx | 540 ++++ .../wholesale/admin/PriceSheetModal.tsx | 93 + .../wholesale/admin/ProductsTab.tsx | 264 ++ .../wholesale/admin/SettingsTab.tsx | 287 +++ .../wholesale/admin/StatusBadge.tsx | 25 + .../admin/WebhookSettingsSection.tsx | 167 ++ .../wholesale/admin/WholesaleIcon.tsx | 9 + .../admin/WholesaleLoadingSkeleton.tsx | 47 + src/components/wholesale/admin/types.ts | 29 + 14 files changed, 2317 insertions(+), 2231 deletions(-) create mode 100644 src/components/wholesale/admin/AddRecipientForm.tsx create mode 100644 src/components/wholesale/admin/CustomerPricingPanel.tsx create mode 100644 src/components/wholesale/admin/CustomersTab.tsx create mode 100644 src/components/wholesale/admin/DashboardTab.tsx create mode 100644 src/components/wholesale/admin/OrdersTab.tsx create mode 100644 src/components/wholesale/admin/PriceSheetModal.tsx create mode 100644 src/components/wholesale/admin/ProductsTab.tsx create mode 100644 src/components/wholesale/admin/SettingsTab.tsx create mode 100644 src/components/wholesale/admin/StatusBadge.tsx create mode 100644 src/components/wholesale/admin/WebhookSettingsSection.tsx create mode 100644 src/components/wholesale/admin/WholesaleIcon.tsx create mode 100644 src/components/wholesale/admin/WholesaleLoadingSkeleton.tsx create mode 100644 src/components/wholesale/admin/types.ts diff --git a/src/app/admin/wholesale/WholesaleClient.tsx b/src/app/admin/wholesale/WholesaleClient.tsx index 3c2ec0d..106024e 100644 --- a/src/app/admin/wholesale/WholesaleClient.tsx +++ b/src/app/admin/wholesale/WholesaleClient.tsx @@ -1,57 +1,41 @@ "use client"; -import { useState, useEffect, useRef } from "react"; -import crypto from "crypto"; +import { useEffect, useState } from "react"; import { type WholesaleOrder, type WholesaleCustomer, type WholesaleProduct, type WholesaleSettings, type WholesaleDashboardStats, - type NotificationRecipient, getWholesaleOrders, getWholesaleCustomers, getWholesaleProducts, getWholesaleSettings, getWholesaleDashboardStats, - markWholesaleOrderFulfilled, - updateWholesaleOrderStatus, - deleteWholesaleOrder, - deleteWholesaleCustomer, - deleteWholesaleProduct, - saveWholesaleCustomer, - saveWholesaleProduct, - saveWholesaleSettings, - recordWholesaleDeposit, - bulkFulfillWholesaleOrders, - bulkRecordWholesaleDeposit, - enqueueWholesaleNotification, - getWebhookSettings, - saveWebhookSettings, getRecentWebhookActivity, } from "@/actions/wholesale"; -import DepositModal from "@/components/wholesale/DepositModal"; -import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal"; -import { getPendingWholesaleRegistrations, approveWholesaleRegistration, getWholesaleCustomerPricing, upsertWholesaleCustomerPricing, deleteWholesaleCustomerPricing } from "@/actions/wholesale-register"; +import { getPendingWholesaleRegistrations } from "@/actions/wholesale-register"; import { getCurrentAdminUser } from "@/actions/admin-user"; import { type AdminUser } from "@/lib/admin-permissions"; -import { formatDate } from "@/lib/format-date"; -import { PageHeader, AdminButton, AdminSearchInput, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system"; +import { PageHeader, AdminFilterTabs } from "@/components/admin/design-system"; +import WholesaleIcon from "@/components/wholesale/admin/WholesaleIcon"; +import WholesaleLoadingSkeleton from "@/components/wholesale/admin/WholesaleLoadingSkeleton"; +import DashboardTab from "@/components/wholesale/admin/DashboardTab"; +import CustomersTab from "@/components/wholesale/admin/CustomersTab"; +import ProductsTab from "@/components/wholesale/admin/ProductsTab"; +import OrdersTab from "@/components/wholesale/admin/OrdersTab"; +import SettingsTab from "@/components/wholesale/admin/SettingsTab"; +import type { PendingRegistration, WebhookActivityEntry, MsgKind } from "@/components/wholesale/admin/types"; type Tab = "dashboard" | "customers" | "products" | "orders" | "settings"; -// SVG Icon for Wholesale - defined at module level to prevent recreation on each render -function WholesaleIcon() { - return ( - - - - ); -} - +/** + * Wholesale Portal admin shell. Owns the data-fetch + tab-state and delegates + * rendering to the per-tab components under `src/components/wholesale/admin/`. + */ export default function WholesaleClient({ brandId }: { brandId: string }) { const [tab, setTab] = useState("dashboard"); - const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null); + const [msg, setMsg] = useState<{ kind: MsgKind; text: string } | null>(null); const [adminUser, setAdminUser] = useState(null); // Data state @@ -60,15 +44,9 @@ export default function WholesaleClient({ brandId }: { brandId: string }) { const [products, setProducts] = useState([]); const [settings, setSettings] = useState(null); const [stats, setStats] = useState(null); - const [registrations, setRegistrations] = useState>([]); + const [registrations, setRegistrations] = useState([]); const [loading, setLoading] = useState(true); - const [webhookActivity, setWebhookActivity] = useState>([]); + const [webhookActivity, setWebhookActivity] = useState([]); useEffect(() => { async function load() { @@ -89,14 +67,14 @@ export default function WholesaleClient({ brandId }: { brandId: string }) { setProducts(p); setSettings(s); setStats(st); - setRegistrations(r as typeof registrations); - setWebhookActivity(wa); + setRegistrations(r as PendingRegistration[]); + setWebhookActivity(wa as WebhookActivityEntry[]); setLoading(false); } load(); }, [brandId]); - function showMsg(kind: "success" | "error", text: string) { + function showMsg(kind: MsgKind, text: string) { setMsg({ kind, text }); setTimeout(() => setMsg(null), 4000); } @@ -117,13 +95,10 @@ export default function WholesaleClient({ brandId }: { brandId: string }) { { value: "settings", label: "Settings" }, ]; - // Declare icon reference for PageHeader - const pageIcon = ; - return (
} title="Wholesale Portal" subtitle="Manage wholesale orders, customers, and products" className="mb-0" @@ -150,12 +125,10 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
)} - {tab === "dashboard" && ( + {tab === "dashboard" && stats && ( )} @@ -168,7 +141,7 @@ export default function WholesaleClient({ brandId }: { brandId: string }) { registrations={registrations} onRefresh={async () => { const r = await getPendingWholesaleRegistrations(brandId); - setRegistrations(r as typeof registrations); + setRegistrations(r as PendingRegistration[]); }} /> )} @@ -210,2183 +183,4 @@ export default function WholesaleClient({ brandId }: { brandId: string }) { ); -} - -// ── Dashboard Tab ───────────────────────────────────────────────────────────── - -function DashboardTab({ stats, recentOrders, brandId, onMsg, webhookActivity }: { - stats: WholesaleDashboardStats; - recentOrders: WholesaleOrder[]; - brandId: string; - onMsg: (kind: "success" | "error", text: string) => void; - webhookActivity: Array<{ - id: string; event_type: string; order_id: string | null; - status: string; attempts: number; created_at: string; response: string | null; - }>; -}) { - return ( -
- {/* Stat cards */} -
- {[ - { label: "Open Orders", value: stats.open_orders, variant: "default" }, - { label: "Pickup Today", value: stats.pickup_today, variant: "default" }, - { label: "Past Due", value: stats.past_due, variant: "danger" }, - { label: "Total Unpaid", value: `$${stats.total_unpaid.toFixed(2)}`, variant: "default" }, - { label: "Awaiting Deposit", value: stats.awaiting_deposit, variant: "warning" }, - { label: "Fulfilled Today", value: stats.fulfilled_today, variant: "success" }, - ].map((card) => ( -
-

{card.label}

-

{card.value}

-
- ))} -
- - {/* Recent orders */} -
-

Recent Orders

- {recentOrders.length === 0 ? ( -
-
- - - -
-

No wholesale orders yet

-

Wholesale orders placed by your customers will appear here.

-
- ) : ( -
- - - - - - - - - - - - - {recentOrders.map((order) => ( - - - - - - - - - ))} - -
InvoiceCustomerPickup DateTotalStatusPayment
{order.invoice_number ?? "—"}{order.company_name}{order.anticipated_pickup_date ?? "—"}${Number(order.subtotal).toFixed(2)} - - - - {order.payment_status === "paid" ? "Paid" : order.balance_due > 0 ? `$${Number(order.balance_due).toFixed(2)} due` : "Partial"} - -
-
- )} -
- - {/* Recent webhook activity */} - {webhookActivity.length > 0 && ( -
-

Recent Webhook Activity

-
- - - - - - - - - - - - {webhookActivity.map((entry) => ( - - - - - - - - ))} - -
EventOrderStatusAttemptsSent At
- {entry.event_type} - {entry.order_id ? entry.order_id.slice(0, 8) : "—"} - - {entry.status} - - {entry.attempts}{new Date(entry.created_at).toLocaleString()}
-
-
- )} -
- ); -} - -// ── Customers Tab ──────────────────────────────────────────────────────────── - -function CustomersTab({ customers, products, brandId, onMsg, registrations = [], onRefresh }: { - customers: WholesaleCustomer[]; - products: WholesaleProduct[]; - brandId: string; - onMsg: (kind: "success" | "error", text: string) => void; - registrations?: Array<{ - id: string; company_name: string | null; contact_name: string | null; - email: string; phone: string | null; account_status: string; created_at: string; - }>; - onRefresh: () => void; -}) { - const [showForm, setShowForm] = useState(false); - const [editing, setEditing] = useState(null); - const [subTab, setSubTab] = useState<"customers" | "registrations">("customers"); - const [form, setForm] = useState({ - companyName: "", contactName: "", email: "", phone: "", - accountStatus: "active", creditLimit: 0, - depositsEnabled: false, depositThreshold: "", depositPercentage: "", - orderEmail: "", invoiceEmail: "", adminNotes: "", - }); - const [saving, setSaving] = useState(false); - const [processingReg, setProcessingReg] = useState(null); - const [pricingCustomer, setPricingCustomer] = useState(null); - const [selectedCustomers, setSelectedCustomers] = useState>(new Set()); - const [sendingPriceSheet, setSendingPriceSheet] = useState(false); - const [priceSheetTarget, setPriceSheetTarget] = useState<{ - customerIds: string[]; - defaultSubject: string; - } | null>(null); - const [openCustomerActions, setOpenCustomerActions] = useState(null); - const [deletingCustomer, setDeletingCustomer] = useState(null); - - function toggleSelectCustomer(id: string) { - setSelectedCustomers(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - }); - } - - function toggleAllCustomers() { - if (selectedCustomers.size === customers.length) { - setSelectedCustomers(new Set()); - } else { - setSelectedCustomers(new Set(customers.map(c => c.id))); - } - } - - function openPriceSheetModal(customerIds: string[]) { - const ids = customerIds; - if (ids.length === 0) return; - // Generate default subject from brand name in settings - const brandName = ""; // will be filled by the API call - setPriceSheetTarget({ - customerIds: ids, - defaultSubject: `Wholesale Price Sheet — ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`, - }); - } - - async function handleSendPriceSheet(customerId?: string) { - const ids = customerId ? [customerId] : Array.from(selectedCustomers); - if (ids.length === 0) return; - openPriceSheetModal(ids); - } - - async function handleConfirmPriceSheet(subject: string, customNote: string) { - if (!priceSheetTarget) return; - setSendingPriceSheet(true); - setPriceSheetTarget(null); - try { - const res = await fetch("/api/wholesale/price-sheet", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - customerIds: priceSheetTarget.customerIds, - brandId, - subject, - customNote: customNote || undefined, - }), - }); - const data = await res.json(); - if (res.ok) { - onMsg("success", `Price sheet sent to ${data.enqueued} customer(s).`); - setSelectedCustomers(new Set()); - } else { - onMsg("error", data.error ?? "Failed to send price sheet."); - } - } catch { - onMsg("error", "Failed to send price sheet."); - } finally { - setSendingPriceSheet(false); - } - } - - async function handleApproveReject(regId: string, action: "approve" | "reject") { - setProcessingReg(regId); - const result = await approveWholesaleRegistration(regId, brandId, action); - setProcessingReg(null); - if (result.success) { - onMsg("success", action === "approve" ? "Registration approved." : "Registration rejected."); - onRefresh(); - } else { - onMsg("error", result.error ?? "Failed to process registration."); - } - } - - // Close customer actions dropdown when clicking outside - useEffect(() => { - function handleClick(e: MouseEvent) { - if (!(e.target as Element).closest(".customer-actions-cell")) { - setOpenCustomerActions(null); - } - } - document.addEventListener("click", handleClick); - return () => document.removeEventListener("click", handleClick); - }, []); - - function toggleCustomerActions(customerId: string, e: React.MouseEvent) { - e.stopPropagation(); - setOpenCustomerActions(prev => prev === customerId ? null : customerId); - } - - async function handleDeleteCustomer(customerId: string) { - if (!confirm("Delete this customer? This cannot be undone. Customers with existing orders cannot be deleted.")) return; - setDeletingCustomer(customerId); - const result = await deleteWholesaleCustomer(customerId); - setDeletingCustomer(null); - if (result.success) { - onMsg("success", "Customer deleted."); - onRefresh(); - } else { - onMsg("error", result.error ?? "Failed to delete customer."); - } - } - - async function handleSave() { - setSaving(true); - const result = await saveWholesaleCustomer({ - brandId, - userId: editing?.user_id ?? undefined, - companyName: form.companyName || undefined, - contactName: form.contactName || undefined, - email: form.email || undefined, - phone: form.phone || undefined, - accountStatus: form.accountStatus, - creditLimit: form.creditLimit, - depositsEnabled: form.depositsEnabled, - depositThreshold: form.depositThreshold ? Number(form.depositThreshold) : undefined, - depositPercentage: form.depositPercentage ? Number(form.depositPercentage) : undefined, - orderEmail: form.orderEmail || undefined, - invoiceEmail: form.invoiceEmail || undefined, - adminNotes: form.adminNotes || undefined, - }); - setSaving(false); - if (result.success) { - onMsg("success", editing ? "Customer updated." : "Customer created."); - setShowForm(false); - onRefresh(); - } else { - onMsg("error", result.error ?? "Failed to save."); - } - } - - function openNew() { - setEditing(null); - setForm({ companyName: "", contactName: "", email: "", phone: "", accountStatus: "active", creditLimit: 0, depositsEnabled: false, depositThreshold: "", depositPercentage: "", orderEmail: "", invoiceEmail: "", adminNotes: "" }); - setShowForm(true); - } - - function openEdit(c: WholesaleCustomer) { - setEditing(c); - setForm({ - companyName: c.company_name ?? "", - contactName: c.contact_name ?? "", - email: c.email ?? "", - phone: c.phone ?? "", - accountStatus: c.account_status ?? "active", - creditLimit: Number(c.credit_limit), - depositsEnabled: c.deposits_enabled, - depositThreshold: c.deposit_threshold?.toString() ?? "", - depositPercentage: c.deposit_percentage?.toString() ?? "", - orderEmail: c.order_email ?? "", - invoiceEmail: c.invoice_email ?? "", - adminNotes: c.admin_notes ?? "", - }); - setShowForm(true); - } - - return ( -
- {/* Sub-tab nav */} -
- setSubTab("customers")} - > - Customers ({customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length}) - - setSubTab("registrations")} - > - Registrations ({registrations.filter(r => r.account_status === "pending_approval").length}) - - {subTab === "customers" && ( - - + Add Customer - - )} -
- - {subTab === "registrations" && ( -
-

Pending Registrations

- {registrations.filter(r => r.account_status === "pending_approval").length === 0 ? ( -

No pending registrations.

- ) : ( -
- - - - - - - - - - - - {registrations.filter(r => r.account_status === "pending_approval").map(r => ( - - - - - - - - ))} - -
CompanyContactStatusRegistered
{r.company_name ?? "—"}{r.contact_name ?? "—"}
{r.email}
- Pending - {formatDate(new Date(r.created_at))} -
- handleApproveReject(r.id, "approve")} - disabled={processingReg === r.id} - isLoading={processingReg === r.id} - > - {processingReg === r.id ? "..." : "Approve"} - - handleApproveReject(r.id, "reject")} - disabled={processingReg === r.id} - > - Reject - -
-
-
- )} -
- )} - - {subTab === "customers" && ( - <> - {showForm && ( -
-

{editing ? "Edit Customer" : "New Customer"}

-
-
- - setForm(f => ({ ...f, companyName: e.target.value }))} - className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> -
-
- - setForm(f => ({ ...f, contactName: e.target.value }))} - className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> -
-
- - setForm(f => ({ ...f, email: e.target.value }))} - className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="email" /> -
-
- - setForm(f => ({ ...f, phone: e.target.value }))} - className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="tel" /> -
-
- - -
-
- - setForm(f => ({ ...f, creditLimit: Number(e.target.value) }))} - className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0 = unlimited" /> -
-
- -
-

Deposit Rules

-
- setForm(f => ({ ...f, depositsEnabled: e.target.checked }))} - className="rounded" id="dep-enabled" /> - -
- {form.depositsEnabled && ( -
-
- - setForm(f => ({ ...f, depositThreshold: e.target.value }))} - className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" /> -
-
- - setForm(f => ({ ...f, depositPercentage: e.target.value }))} - className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" /> -
-
- )} -
- -
- - {saving ? "Saving..." : "Save Customer"} - - setShowForm(false)} variant="secondary"> - Cancel - -
-
- )} - -
- - - - - - - - - - - - - - - {customers.length === 0 ? ( - - - - ) : customers.map(c => ( - - - - - - - - - - - ))} - - {selectedCustomers.size > 0 && ( - - - - - - )} -
- 0} - onChange={toggleAllCustomers} - /> - CompanyContactStatusCreditDepositsCreated
-
-
- - - -
-

No customers yet

-

Wholesale customers will appear here once registered.

-
-
- toggleSelectCustomer(c.id)} - /> - {c.company_name ?? "—"}{c.contact_name ?? "—"}
{c.email}
- - {c.account_status} - - - {c.credit_limit <= 0 ? "Unlimited" : `$${Number(c.credit_limit).toFixed(2)}`} - - {c.deposits_enabled ? `${c.deposit_percentage}%` : "—"} - {formatDate(new Date(c.created_at))} -
- -
- - {openCustomerActions === c.id && ( -
e.stopPropagation()} - > - - - - - View Portal As - -
- -
- )} -
-
-
- {selectedCustomers.size} selected - openPriceSheetModal(Array.from(selectedCustomers))} - disabled={sendingPriceSheet} - variant="primary" - size="sm" - isLoading={sendingPriceSheet} - > - {sendingPriceSheet ? "Sending..." : `Send Price Sheet to ${selectedCustomers.size} Customer(s)`} - -
-
- - )} - - {/* Customer Pricing Overlays Panel */} - {pricingCustomer && ( - setPricingCustomer(null)} - onMsg={onMsg} - /> - )} - - {/* Price Sheet Modal */} - {priceSheetTarget && ( - { setPriceSheetTarget(null); setSendingPriceSheet(false); }} - /> - )} -
- ); -} - -// ── Customer Pricing Panel ────────────────────────────────────────────────────── - -function CustomerPricingPanel({ customer, products, onClose, onMsg }: { - customer: WholesaleCustomer; - products: WholesaleProduct[]; - onClose: () => void; - onMsg: (kind: "success" | "error", text: string) => void; -}) { - const [overrides, setOverrides] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - useEffect(() => { - getWholesaleCustomerPricing(customer.id).then(pricing => { - const map: Record = {}; - for (const p of pricing) { - map[p.product_id] = p.custom_unit_price.toString(); - } - setOverrides(map); - setLoading(false); - }); - }, [customer.id]); - - async function handleSave(productId: string, price: string) { - if (!price || isNaN(Number(price))) { - await deleteWholesaleCustomerPricing({ customerId: customer.id, productId }); - setOverrides(prev => { const n = { ...prev }; delete n[productId]; return n; }); - } else { - await upsertWholesaleCustomerPricing({ customerId: customer.id, productId, customUnitPrice: Number(price) }); - setOverrides(prev => ({ ...prev, [productId]: price })); - } - onMsg("success", "Pricing updated."); - } - - return ( -
-
-
-
-

Pricing Overrides

-

{customer.company_name}

-
- -
-
- {loading ? ( -

Loading...

- ) : products.length === 0 ? ( -

No products available. Add wholesale products to see them here.

- ) : ( - - - - - - - - - - - {products.map(p => { - const standardPrice = p.price_tiers?.[0]?.price ?? 0; - const overridePrice = overrides[p.id] ?? ""; - return ( - - - - - - - ); - })} - -
ProductStandard PriceOverride Price
{p.name}${standardPrice.toFixed(2)} -
- $ - setOverrides(prev => ({ ...prev, [p.id]: e.target.value }))} - placeholder={standardPrice.toFixed(2)} - className="w-24 rounded-lg border border-[var(--admin-border)] px-2 py-1 text-sm outline-none focus:border-[var(--admin-accent)]" - /> - {overridePrice && overridePrice !== standardPrice.toFixed(2) && ( - Custom - )} -
-
- handleSave(p.id, overrides[p.id] ?? "")} - disabled={saving} - > - Save - -
- )} -

- Leave override price blank to remove custom pricing and use standard price tiers. -

-
-
-
- ); -} - -// ── Price Sheet Modal ────────────────────────────────────────────────────────── - -function PriceSheetModal({ - customerCount, - defaultSubject, - onConfirm, - onClose, -}: { - customerCount: number; - defaultSubject: string; - onConfirm: (subject: string, customNote: string) => void; - onClose: () => void; -}) { - const [subject, setSubject] = useState(defaultSubject); - const [note, setNote] = useState(""); - const [sending, setSending] = useState(false); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!subject.trim()) return; - setSending(true); - onConfirm(subject.trim(), note.trim()); - } - - return ( -
-
e.stopPropagation()}> -
-
-

Send Price Sheet

-

- {customerCount === 1 ? "1 customer" : `${customerCount} customers`} -

-
- -
- -
-
-
- - setSubject(e.target.value)} - required - aria-required="true" - className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" - /> -
-
- -