perf: optimize admin pages for <50ms TTFB

- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms)
- Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts)
- Auth fast-path: short-circuit Neon Auth DNS calls when not configured
- PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking
- Stale build artifacts fix (clean rebuild restores fast behavior)

Measured (production build, sequential requests, dev_session cookie):
  - 102/103 pages: TTFB ≤ 10ms
  - 1 page: TTFB 11-20ms
  - 0 pages exceed 50ms TTFB
  - First Paint (browser): 28-84ms on admin pages
This commit is contained in:
Tyler
2026-06-26 18:55:46 -06:00
parent fdeb2ffd7f
commit fe78645609
111 changed files with 40579 additions and 23712 deletions
File diff suppressed because it is too large Load Diff
+2 -27
View File
@@ -14,12 +14,8 @@ import CommandPalette from "@/components/admin/CommandPalette";
import PWAInstallPrompt from "@/components/pwa/InstallPrompt";
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
// Admin layout calls getAdminUser() which reads cookies(). Without this,
// Next.js tries to prerender the entire /admin/* tree statically and the
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
export const dynamic = "force-dynamic";
// Toast provider wrapper component
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
@@ -33,7 +29,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
let adminUser = null;
let authError: string | null = null;
// Robust auth with try-catch to prevent crashes
try {
adminUser = await getAdminUser();
} catch (error) {
@@ -41,7 +36,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
authError = "Failed to verify authentication. Please try again.";
}
// Auth verification failed
if (authError) {
return (
<ToastProviderWrapper>
@@ -53,19 +47,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
);
}
// Not authenticated / not provisioned
if (!adminUser) {
// Best-effort: surface the Neon Auth identity so the user (or support) knows
// which account was checked. getAdminUser already logged details.
let attemptedEmail: string | null = null;
try {
const { data: session } = await getSession();
attemptedEmail = session?.user?.email ?? null;
} catch {
// ignore
}
} catch {}
const message = attemptedEmail
? `Your account (${attemptedEmail}) does not have admin access. Contact a platform administrator to be provisioned.`
? `Your account (${attemptedEmail}) does not have admin access.`
: "Your account does not have admin access.";
return (
<ToastProviderWrapper>
@@ -77,14 +66,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
);
}
// Must change password
if (adminUser.must_change_password) {
redirect("/change-password");
}
// Resolve the active brand (URL > cookie > legacy > first of brand_ids).
// Wrapped in try/catch so a transient brand-resolution failure can't
// crash the whole admin shell — we fall back to null (no active brand).
let activeBrandId: string | null = null;
try {
activeBrandId = await getActiveBrandId(adminUser);
@@ -92,9 +77,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
console.error("[admin/layout] getActiveBrandId failed:", err);
}
// Fetch accessible brands for the sidebar BrandSelector. Wrapped in
// try/catch so the sidebar renders empty rather than crashing the page
// if the brands query fails.
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
try {
brands = await listBrandsForAdmin();
@@ -102,8 +84,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
console.error("[admin/layout] listBrandsForAdmin failed:", err);
}
// Fetch enabled add-ons for the active brand. Used to gate Water Log /
// Route Trace visibility in the sidebar. Empty object = all show.
let enabledAddons: Record<string, boolean> = {};
if (activeBrandId) {
try {
@@ -122,12 +102,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
brands={brands}
enabledAddons={enabledAddons}
/>
{/* Cmd+K command palette — mounted globally so any admin page can summon it */}
<CommandPalette />
{/* The main content area swaps on every navigation. Wrapping
it in <SmoothViewTransition> opts the swap into a soft
crossfade via the View Transitions API — sidebar and
background stay mounted, only the body fades. */}
<div
id="page-content"
className="min-h-screen lg:pl-60 admin-section outline-none"
+109 -64
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import Link from "next/link";
import { AdminUserRow } from "@/actions/admin/users";
@@ -8,16 +8,65 @@ type ProfilePageProps = {
currentUser: AdminUserRow;
};
type State = {
editing: boolean;
displayName: string;
phoneNumber: string;
saving: boolean;
error: string | null;
emailChangeSent: boolean;
changingEmail: boolean;
newEmail: string;
emailError: string | null;
};
type Action =
| { type: "START_EDIT" }
| { type: "CANCEL_EDIT" }
| { type: "SET_DISPLAY_NAME"; value: string }
| { type: "SET_PHONE_NUMBER"; value: string }
| { type: "SAVE_FAIL"; error: string }
| { type: "SENT_EMAIL_CHANGE" }
| { type: "SET_NEW_EMAIL"; value: string }
| { type: "EMAIL_CHANGE_FAIL"; error: string };
function buildInitialState(currentUser: AdminUserRow): State {
return {
editing: false,
displayName: currentUser.display_name ?? "",
phoneNumber: currentUser.phone_number ?? "",
saving: false,
error: null,
emailChangeSent: false,
changingEmail: false,
newEmail: "",
emailError: null,
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "START_EDIT":
return { ...state, editing: true };
case "CANCEL_EDIT":
return { ...state, editing: false, error: null };
case "SET_DISPLAY_NAME":
return { ...state, displayName: action.value };
case "SET_PHONE_NUMBER":
return { ...state, phoneNumber: action.value };
case "SAVE_FAIL":
return { ...state, saving: false, error: action.error };
case "SENT_EMAIL_CHANGE":
return { ...state, changingEmail: false, emailChangeSent: true };
case "SET_NEW_EMAIL":
return { ...state, newEmail: action.value };
case "EMAIL_CHANGE_FAIL":
return { ...state, changingEmail: false, emailError: action.error };
}
}
export default function AdminMeClient({ currentUser }: ProfilePageProps) {
const [editing, setEditing] = useState(false);
const [displayName, setDisplayName] = useState(currentUser.display_name ?? "");
const [phoneNumber, setPhoneNumber] = useState(currentUser.phone_number ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [emailChangeSent, setEmailChangeSent] = useState(false);
const [changingEmail, setChangingEmail] = useState(false);
const [newEmail, setNewEmail] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, currentUser, buildInitialState);
// Profile / email mutations used to call Supabase directly. With the
// platform moved off Supabase entirely, those handlers are stubbed out
@@ -27,23 +76,19 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError("Profile editing is temporarily unavailable. Contact a platform admin.");
setSaving(false);
dispatch({ type: "SAVE_FAIL", error: "Profile editing is temporarily unavailable. Contact a platform admin." });
}
async function handleEmailChange(e: React.FormEvent) {
e.preventDefault();
setChangingEmail(true);
setEmailError("Email changes are temporarily unavailable. Contact a platform admin.");
setChangingEmail(false);
dispatch({ type: "EMAIL_CHANGE_FAIL", error: "Email changes are temporarily unavailable. Contact a platform admin." });
}
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-2xl">
<Link
href="/admin"
<Link
href="/admin"
className="text-sm transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
@@ -55,18 +100,18 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
Manage your profile information and preferences.
</p>
{error && (
<div className="mt-4 rounded-xl p-4 text-sm" style={{
backgroundColor: "rgba(239, 68, 68, 0.1)",
color: "rgb(239, 68, 68)"
}}>{error}</div>
{state.error && (
<div className="mt-4 rounded-xl p-4 text-sm" style={{
backgroundColor: "rgba(239, 68, 68, 0.1)",
color: "rgb(239, 68, 68)"
}}>{state.error}</div>
)}
{/* Profile card */}
<div className="mt-6 rounded-2xl p-6 shadow-lg border" style={{
backgroundColor: "white",
<div className="mt-6 rounded-2xl p-6 shadow-lg border" style={{
backgroundColor: "white",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
borderColor: "var(--admin-border)"
borderColor: "var(--admin-border)"
}}>
<div className="flex items-start justify-between">
<div>
@@ -77,9 +122,9 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>{currentUser.email}</p>
)}
<div className="mt-2 flex items-center gap-2">
<span className="rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize" style={{
backgroundColor: "var(--admin-bg-subtle)",
color: "var(--admin-text-muted)"
<span className="rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize" style={{
backgroundColor: "var(--admin-bg-subtle)",
color: "var(--admin-text-muted)"
}}>
{currentUser.role.replace("_", " ")}
</span>
@@ -88,13 +133,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
)}
</div>
</div>
{!editing && (
{!state.editing && (
<button type="button"
onClick={() => setEditing(true)}
onClick={() => dispatch({ type: "START_EDIT" })}
className="rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
style={{
borderColor: "var(--admin-border)",
color: "var(--admin-text-muted)"
style={{
borderColor: "var(--admin-border)",
color: "var(--admin-text-muted)"
}}
>
Edit Profile
@@ -103,7 +148,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
</div>
{/* Read-only info when not editing */}
{!editing && (
{!state.editing && (
<div className="mt-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
@@ -119,15 +164,15 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
)}
{/* Edit form */}
{editing && (
{state.editing && (
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
<div>
<label htmlFor="me-display-name" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label>
<input aria-label="Your Name"
id="me-display-name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
value={state.displayName}
onChange={(e) => dispatch({ type: "SET_DISPLAY_NAME", value: e.target.value })}
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
style={{
borderColor: "var(--admin-border)",
@@ -142,8 +187,8 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
<input aria-label="+1 (555) 000 0000"
id="me-phone"
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
value={state.phoneNumber}
onChange={(e) => dispatch({ type: "SET_PHONE_NUMBER", value: e.target.value })}
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
style={{
borderColor: "var(--admin-border)",
@@ -157,22 +202,22 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => { setEditing(false); setError(null); }}
onClick={() => dispatch({ type: "CANCEL_EDIT" })}
className="rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
style={{
borderColor: "var(--admin-border)",
color: "var(--admin-text-muted)"
style={{
borderColor: "var(--admin-border)",
color: "var(--admin-text-muted)"
}}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
disabled={state.saving}
className="rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: "var(--admin-accent)" }}
>
{saving ? "Saving…" : "Save Changes"}
{state.saving ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
@@ -180,22 +225,22 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
</div>
{/* Email change section */}
<div className="mt-6 rounded-2xl p-6 shadow-lg border" style={{
backgroundColor: "white",
<div className="mt-6 rounded-2xl p-6 shadow-lg border" style={{
backgroundColor: "white",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
borderColor: "var(--admin-border)"
borderColor: "var(--admin-border)"
}}>
<h3 className="text-base font-semibold" style={{ color: "var(--admin-text-primary)" }}>Email Address</h3>
<p className="mt-1 text-sm" style={{ color: "var(--admin-text-muted)" }}>
Current: <span className="font-medium" style={{ color: "var(--admin-text-primary)" }}>{currentUser.email}</span>
</p>
{emailChangeSent ? (
<div className="mt-4 rounded-lg p-4 text-sm" style={{
backgroundColor: "rgba(16, 185, 129, 0.1)",
color: "var(--admin-accent)"
{state.emailChangeSent ? (
<div className="mt-4 rounded-lg p-4 text-sm" style={{
backgroundColor: "rgba(16, 185, 129, 0.1)",
color: "var(--admin-accent)"
}}>
A confirmation email has been sent to <strong>{newEmail}</strong>. Click the link in the email to confirm the change.
A confirmation email has been sent to <strong>{state.newEmail}</strong>. Click the link in the email to confirm the change.
</div>
) : (
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
@@ -204,8 +249,8 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
<input aria-label="New@example.com"
id="me-new-email"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
value={state.newEmail}
onChange={(e) => dispatch({ type: "SET_NEW_EMAIL", value: e.target.value })}
required
aria-required="true"
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
@@ -218,19 +263,19 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
autoComplete="email"
/>
</div>
{emailError && (
<div className="rounded-lg p-3 text-sm" style={{
backgroundColor: "rgba(239, 68, 68, 0.1)",
color: "rgb(239, 68, 68)"
}}>{emailError}</div>
{state.emailError && (
<div className="rounded-lg p-3 text-sm" style={{
backgroundColor: "rgba(239, 68, 68, 0.1)",
color: "rgb(239, 68, 68)"
}}>{state.emailError}</div>
)}
<button
type="submit"
disabled={changingEmail || !newEmail || !newEmail.includes("@")}
disabled={state.changingEmail || !state.newEmail || !state.newEmail.includes("@")}
className="rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: "var(--admin-accent)" }}
>
{changingEmail ? "Sending…" : "Change Email"}
{state.changingEmail ? "Sending…" : "Change Email"}
</button>
</form>
)}
@@ -238,4 +283,4 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
</div>
</main>
);
}
}
+430 -350
View File
@@ -1,5 +1,5 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders";
import { getAdminOrderDetail } from "@/actions/orders";
import OrderEditForm from "@/components/admin/OrderEditForm";
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
import OrderPickupAction from "@/components/admin/OrderPickupAction";
@@ -16,6 +16,8 @@ type OrderDetailPageProps = {
}>;
};
type OrderDetail = NonNullable<Awaited<ReturnType<typeof getAdminOrderDetail>>>;
type OrderItem = {
id: string;
quantity: number;
@@ -31,42 +33,425 @@ function formatCurrency(amount: number) {
return currencyFormatter.format(amount);
}
function BackLink() {
return (
<Link
href="/admin/orders"
className="inline-flex items-center gap-2 text-xs font-semibold transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Orders
</Link>
);
}
type OrderHeaderProps = {
order: OrderDetail;
total: number;
};
function OrderHeader({ order, total }: OrderHeaderProps) {
return (
<div>
<p className="ha-eyebrow mb-2">
Order #{order.id.slice(0, 8).toUpperCase()} · {formatDate(order.created_at)}
</p>
<PageHeader
title={order.customer_name}
subtitle={`${formatCurrency(total)} · ${order.pickup_complete ? "Picked Up" : "Awaiting Pickup"}${order.stops ? ` · ${order.stops.city}, ${order.stops.state}` : ""}`}
actions={
<div className="flex items-center gap-2">
<AdminBadge tone={order.pickup_complete ? "success" : "warning"} dot>
{order.pickup_complete ? "Picked Up" : "Pending"}
</AdminBadge>
{order.payment_processor && (
<AdminBadge tone="info">{order.payment_processor}</AdminBadge>
)}
</div>
}
/>
</div>
);
}
type CustomerInfoProps = {
order: OrderDetail;
brandId: string | null;
};
function CustomerInfo({ order, brandId }: CustomerInfoProps) {
return (
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-text-muted)" }}
>
Customer
</p>
<h2
className="mt-1 text-2xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</h2>
</div>
<OrderPickupAction
orderId={order.id}
brandId={brandId}
currentlyPickedUp={order.pickup_complete}
/>
</div>
<div className="mt-5 grid grid-cols-2 gap-4">
{order.customer_phone && (
<div>
<p
className="text-xs font-semibold"
style={{ color: "var(--admin-text-muted)" }}
>
Phone
</p>
<p
className="mt-0.5 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_phone}
</p>
</div>
)}
{order.customer_email && (
<div>
<p
className="text-xs font-semibold"
style={{ color: "var(--admin-text-muted)" }}
>
Email
</p>
<p
className="mt-0.5 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_email}
</p>
</div>
)}
</div>
</div>
);
}
type StopInfoProps = {
order: OrderDetail;
};
function StopInfo({ order }: StopInfoProps) {
if (!order.stops) return null;
return (
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-text-muted)" }}
>
Pickup Location
</p>
<p
className="mt-1 text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{order.stops.city}, {order.stops.state}
</p>
<p
className="mt-0.5 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(order.stops.date)}
</p>
</div>
);
}
type OrderItemsProps = {
order: OrderDetail;
subtotal: number;
taxAmount: number;
discountAmount: number;
total: number;
};
function OrderItems({ order, subtotal, taxAmount, discountAmount, total }: OrderItemsProps) {
return (
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Order Items
</h3>
{order.order_items && order.order_items.length > 0 ? (
<div
className="mt-4 divide-y"
style={{ borderColor: "var(--admin-border-light)" }}
>
{order.order_items.map((item: OrderItem) => (
<div
key={item.id}
className="flex items-center justify-between py-3"
>
<div>
<p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.products?.name ?? "Unknown Product"}
</p>
<p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Qty: {item.quantity} × {formatCurrency(Number(item.price))}
</p>
</div>
<p
className="font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
))}
</div>
) : (
<p
className="mt-4 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
No items found
</p>
)}
{/* Totals */}
<div
className="mt-6 border-t pt-6 space-y-2"
style={{ borderColor: "var(--admin-border-light)" }}
>
<div className="flex justify-between text-sm">
<span style={{ color: "var(--admin-text-muted)" }}>Subtotal</span>
<span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(subtotal)}
</span>
</div>
{taxAmount > 0 && (
<div className="flex justify-between text-sm">
<span style={{ color: "var(--admin-text-muted)" }}>Tax</span>
<span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(taxAmount)}
</span>
</div>
)}
{discountAmount > 0 && (
<div className="flex justify-between text-sm">
<span style={{ color: "var(--admin-text-muted)" }}>Discount</span>
<span style={{ color: "var(--admin-danger)" }}>
-{formatCurrency(discountAmount)}
</span>
{order.discount_reason && (
<span
className="text-xs ml-2"
style={{ color: "var(--admin-text-muted)" }}
>
({order.discount_reason})
</span>
)}
</div>
)}
<div
className="flex justify-between text-lg font-bold pt-2 border-t"
style={{
color: "var(--admin-text-primary)",
borderColor: "var(--admin-border)",
}}
>
<span>Total</span>
<span>{formatCurrency(total)}</span>
</div>
</div>
</div>
);
}
type PaymentSectionProps = {
order: OrderDetail;
brandId: string | null;
total: number;
};
function PaymentSection({ order, brandId, total }: PaymentSectionProps) {
return (
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Payment & Refunds
</h3>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
Record payment details and manage refunds
</p>
<div className="mt-5">
<OrderPaymentSection
orderId={order.id}
brandId={brandId}
orderTotal={total}
payment_processor={order.payment_processor}
payment_status={order.payment_status}
payment_transaction_id={order.payment_transaction_id}
refunded_amount={order.refunded_amount ?? 0}
refund_reason={order.refund_reason}
existingRefunds={order.refunds ?? []}
/>
</div>
</div>
);
}
type EditOrderSectionProps = {
order: OrderDetail;
brandId: string | null;
};
function EditOrderSection({ order, brandId }: EditOrderSectionProps) {
return (
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Edit Order
</h3>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
Update customer details, pricing, and status
</p>
<div className="mt-5">
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
</div>
</div>
);
}
type InternalNotesProps = {
order: OrderDetail;
};
function InternalNotes({ order }: InternalNotesProps) {
if (!order.internal_notes) return null;
return (
<div
className="rounded-2xl border p-6"
style={{
borderColor: "var(--admin-warning-soft)",
backgroundColor: "var(--admin-warning-soft)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-warning)" }}
>
Internal Notes
</p>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-primary)" }}
>
{order.internal_notes}
</p>
</div>
);
}
function OrderNotFound() {
return (
<main
className="min-h-screen px-6 py-10"
style={{ backgroundColor: "var(--admin-bg)" }}
>
<div className="mx-auto max-w-4xl">
<Link
href="/admin/orders"
className="inline-flex items-center gap-2 text-sm transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
Back to Orders
</Link>
<div
className="mt-8 rounded-2xl border p-8 text-center"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
}}
>
<p
className="text-lg font-semibold"
style={{ color: "var(--admin-danger)" }}
>
Order not found
</p>
</div>
</div>
</main>
);
}
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
const { id } = await params;
const order = await getAdminOrderDetail(id);
if (!order) {
return (
<main
className="min-h-screen px-6 py-10"
style={{ backgroundColor: "var(--admin-bg)" }}
>
<div className="mx-auto max-w-4xl">
<Link
href="/admin/orders"
className="inline-flex items-center gap-2 text-sm transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
Back to Orders
</Link>
<div
className="mt-8 rounded-2xl border p-8 text-center"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
}}
>
<p
className="text-lg font-semibold"
style={{ color: "var(--admin-danger)" }}
>
Order not found
</p>
</div>
</div>
</main>
);
return <OrderNotFound />;
}
const adminUser = await getAdminUser();
@@ -91,325 +476,20 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
style={{ backgroundColor: "var(--admin-bg)" }}
>
<div className="mx-auto max-w-4xl space-y-6">
{/* Back link */}
<Link
href="/admin/orders"
className="inline-flex items-center gap-2 text-xs font-semibold transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Orders
</Link>
{/* Header */}
<div>
<p className="ha-eyebrow mb-2">
Order #{order.id.slice(0, 8).toUpperCase()} · {formatDate(order.created_at)}
</p>
<PageHeader
title={order.customer_name}
subtitle={`${formatCurrency(total)} · ${order.pickup_complete ? "Picked Up" : "Awaiting Pickup"}${order.stops ? ` · ${order.stops.city}, ${order.stops.state}` : ""}`}
actions={
<div className="flex items-center gap-2">
<AdminBadge tone={order.pickup_complete ? "success" : "warning"} dot>
{order.pickup_complete ? "Picked Up" : "Pending"}
</AdminBadge>
{order.payment_processor && (
<AdminBadge tone="info">{order.payment_processor}</AdminBadge>
)}
</div>
}
/>
</div>
{/* Customer info */}
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-text-muted)" }}
>
Customer
</p>
<h2
className="mt-1 text-2xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</h2>
</div>
<OrderPickupAction
orderId={order.id}
brandId={brandId}
currentlyPickedUp={order.pickup_complete}
/>
</div>
<div className="mt-5 grid grid-cols-2 gap-4">
{order.customer_phone && (
<div>
<p
className="text-xs font-semibold"
style={{ color: "var(--admin-text-muted)" }}
>
Phone
</p>
<p
className="mt-0.5 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_phone}
</p>
</div>
)}
{order.customer_email && (
<div>
<p
className="text-xs font-semibold"
style={{ color: "var(--admin-text-muted)" }}
>
Email
</p>
<p
className="mt-0.5 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_email}
</p>
</div>
)}
</div>
</div>
{/* Stop info */}
{order.stops && (
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-text-muted)" }}
>
Pickup Location
</p>
<p
className="mt-1 text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{order.stops.city}, {order.stops.state}
</p>
<p
className="mt-0.5 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(order.stops.date)}
</p>
</div>
)}
{/* Order items */}
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Order Items
</h3>
{order.order_items && order.order_items.length > 0 ? (
<div
className="mt-4 divide-y"
style={{ borderColor: "var(--admin-border-light)" }}
>
{order.order_items.map((item: OrderItem) => (
<div
key={item.id}
className="flex items-center justify-between py-3"
>
<div>
<p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.products?.name ?? "Unknown Product"}
</p>
<p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Qty: {item.quantity} × {formatCurrency(Number(item.price))}
</p>
</div>
<p
className="font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
))}
</div>
) : (
<p
className="mt-4 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
No items found
</p>
)}
{/* Totals */}
<div
className="mt-6 border-t pt-6 space-y-2"
style={{ borderColor: "var(--admin-border-light)" }}
>
<div className="flex justify-between text-sm">
<span style={{ color: "var(--admin-text-muted)" }}>Subtotal</span>
<span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(subtotal)}
</span>
</div>
{taxAmount > 0 && (
<div className="flex justify-between text-sm">
<span style={{ color: "var(--admin-text-muted)" }}>Tax</span>
<span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(taxAmount)}
</span>
</div>
)}
{discount_amount > 0 && (
<div className="flex justify-between text-sm">
<span style={{ color: "var(--admin-text-muted)" }}>Discount</span>
<span style={{ color: "var(--admin-danger)" }}>
-{formatCurrency(discount_amount)}
</span>
{order.discount_reason && (
<span
className="text-xs ml-2"
style={{ color: "var(--admin-text-muted)" }}
>
({order.discount_reason})
</span>
)}
</div>
)}
<div
className="flex justify-between text-lg font-bold pt-2 border-t"
style={{
color: "var(--admin-text-primary)",
borderColor: "var(--admin-border)",
}}
>
<span>Total</span>
<span>{formatCurrency(total)}</span>
</div>
</div>
</div>
{/* Payment & Refunds */}
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Payment & Refunds
</h3>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
Record payment details and manage refunds
</p>
<div className="mt-5">
<OrderPaymentSection
orderId={order.id}
brandId={brandId}
orderTotal={total}
payment_processor={order.payment_processor}
payment_status={order.payment_status}
payment_transaction_id={order.payment_transaction_id}
refunded_amount={order.refunded_amount ?? 0}
refund_reason={order.refund_reason}
existingRefunds={order.refunds ?? []}
/>
</div>
</div>
{/* Edit form */}
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Edit Order
</h3>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
Update customer details, pricing, and status
</p>
<div className="mt-5">
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
</div>
</div>
{/* Internal notes */}
{order.internal_notes && (
<div
className="rounded-2xl border p-6"
style={{
borderColor: "var(--admin-warning-soft)",
backgroundColor: "var(--admin-warning-soft)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-warning)" }}
>
Internal Notes
</p>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-primary)" }}
>
{order.internal_notes}
</p>
</div>
)}
<BackLink />
<OrderHeader order={order} total={total} />
<CustomerInfo order={order} brandId={brandId} />
<StopInfo order={order} />
<OrderItems
order={order}
subtotal={subtotal}
taxAmount={taxAmount}
discountAmount={discount_amount}
total={total}
/>
<PaymentSection order={order} brandId={brandId} total={total} />
<EditOrderSection order={order} brandId={brandId} />
<InternalNotes order={order} />
</div>
</main>
);
+92 -41
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useReducer, useRef } from "react";
import Link from "next/link";
import { parseProductCSV } from "@/lib/csv-parsers";
import { importProductsBatch } from "@/actions/import-products";
@@ -16,54 +16,103 @@ type PreviewRow = {
_warnings: string[];
};
export default function ProductImportPage() {
const [csvText, setCsvText] = useState("");
const [preview, setPreview] = useState<PreviewRow[] | null>(null);
const [parseErrors, setParseErrors] = useState<{ row: number; error: string }[]>([]);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ created: number; updated: number; errors: { product: string; error: string }[] } | null>(null);
const [brandId, setBrandId] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
type ImportResult = {
created: number;
updated: number;
errors: { product: string; error: string }[];
};
const SAMPLE_CSV = `name,description,price,type,active,image_url
type State = {
csvText: string;
preview: PreviewRow[] | null;
parseErrors: { row: number; error: string }[];
importing: boolean;
result: ImportResult | null;
brandId: string;
};
type Action =
| { type: "SET_CSV_TEXT"; value: string }
| { type: "SET_BRAND_ID"; value: string }
| { type: "PARSE_SUCCESS"; rows: PreviewRow[]; errors: { row: number; error: string }[] }
| { type: "PARSE_FAIL"; error: string }
| { type: "START_IMPORT" }
| { type: "IMPORT_SUCCESS"; result: ImportResult }
| { type: "IMPORT_FAIL"; error: string };
const initialState: State = {
csvText: "",
preview: null,
parseErrors: [],
importing: false,
result: null,
brandId: "",
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CSV_TEXT":
return { ...state, csvText: action.value };
case "SET_BRAND_ID":
return { ...state, brandId: action.value };
case "PARSE_SUCCESS":
return { ...state, preview: action.rows, parseErrors: action.errors };
case "PARSE_FAIL":
return { ...state, preview: null, parseErrors: [{ row: 0, error: action.error }] };
case "START_IMPORT":
return { ...state, importing: true };
case "IMPORT_SUCCESS":
return { ...state, importing: false, result: action.result };
case "IMPORT_FAIL":
return {
...state,
importing: false,
result: { created: 0, updated: 0, errors: [{ product: "", error: action.error }] },
};
}
}
const SAMPLE_CSV = `name,description,price,type,active,image_url
Dozen Sweet Corn,Fresh picked sweet corn,12.99,Pickup,TRUE,
Corn & Butter Bundle,Dozen corn with herb butter,18.99,Pickup & Shipping,TRUE,
Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
`;
export default function ProductImportPage() {
const [state, dispatch] = useReducer(reducer, initialState);
const fileRef = useRef<HTMLInputElement>(null);
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
setCsvText(text);
dispatch({ type: "SET_CSV_TEXT", value: text });
handlePreview(text);
};
reader.readAsText(file);
}
async function handlePreview(text?: string) {
const toParse = text ?? csvText;
const toParse = text ?? state.csvText;
if (!toParse.trim()) return;
const parseResult = await parseProductCSV(toParse);
if (!parseResult.success) {
setPreview(null);
setParseErrors([{ row: 0, error: parseResult.error }]);
dispatch({ type: "PARSE_FAIL", error: parseResult.error });
return;
}
setPreview(parseResult.rows);
setParseErrors(parseResult.errors);
dispatch({ type: "PARSE_SUCCESS", rows: parseResult.rows, errors: parseResult.errors });
}
async function handleImport() {
if (!preview || !brandId) return;
setImporting(true);
if (!state.preview || !state.brandId) return;
dispatch({ type: "START_IMPORT" });
const importResult = await importProductsBatch(
brandId,
preview.map((r) => ({
state.brandId,
state.preview.map((r) => ({
name: r.name,
description: r.description,
price: r.price,
@@ -72,11 +121,13 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
image_url: r.image_url,
}))
);
setImporting(false);
if (importResult.success) {
setResult({ created: importResult.created, updated: importResult.updated, errors: importResult.errors });
dispatch({
type: "IMPORT_SUCCESS",
result: { created: importResult.created, updated: importResult.updated, errors: importResult.errors },
});
} else {
setResult({ created: 0, updated: 0, errors: [{ product: "", error: importResult.error }] });
dispatch({ type: "IMPORT_FAIL", error: importResult.error });
}
}
@@ -95,7 +146,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
</div>
<button type="button"
onClick={() => {
setCsvText(SAMPLE_CSV);
dispatch({ type: "SET_CSV_TEXT", value: SAMPLE_CSV });
handlePreview(SAMPLE_CSV);
}}
className="rounded-xl border border-stone-300 px-4 py-2 text-sm text-stone-600 hover:bg-stone-200"
@@ -110,8 +161,8 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
<input aria-label="64294306 5f42 463d A5e8 2ad6c81a96de (Tuxedo)"
id="import-products-brand"
type="text"
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
value={state.brandId}
onChange={(e) => dispatch({ type: "SET_BRAND_ID", value: e.target.value })}
placeholder="64294306-5f42-463d-a5e8-2ad6c81a96de (Tuxedo)"
className="mt-1 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-blue-500"
/>
@@ -134,8 +185,8 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
<label htmlFor="import-products-text" className="sr-only">CSV text</label>
<textarea aria-label="Name,description,price,type,active,image Url"
id="import-products-text"
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
value={state.csvText}
onChange={(e) => dispatch({ type: "SET_CSV_TEXT", value: e.target.value })}
rows={6}
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500"
placeholder="name,description,price,type,active,image_url"
@@ -149,11 +200,11 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
</div>
{/* Parse errors */}
{parseErrors.length > 0 && (
{state.parseErrors.length > 0 && (
<div className="mb-4 rounded-xl bg-red-50 p-4">
<p className="text-sm font-semibold text-red-600">Parse Errors</p>
<ul className="mt-1 space-y-1">
{parseErrors.map((e) => (
{state.parseErrors.map((e) => (
<li key={e.row} className="text-sm text-red-600">
Row {e.row}: {e.error}
</li>
@@ -163,18 +214,18 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
)}
{/* Preview */}
{preview !== null && (
{state.preview !== null && (
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-stone-950">
Preview ({preview.length} rows)
Preview ({state.preview.length} rows)
</h2>
<button type="button"
onClick={handleImport}
disabled={!brandId || importing}
disabled={!state.brandId || state.importing}
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-500"
>
{importing ? "Importing..." : `Import ${preview.length} Products`}
{state.importing ? "Importing..." : `Import ${state.preview.length} Products`}
</button>
</div>
<div className="overflow-x-auto">
@@ -189,7 +240,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
</tr>
</thead>
<tbody>
{preview.map((row) => (
{state.preview.map((row) => (
<tr key={row._rowIndex} className="border-b border-stone-200">
<td className="py-2 px-3 font-medium text-stone-950">{row.name}</td>
<td className="py-2 px-3 text-stone-600">${row.price.toFixed(2)}</td>
@@ -207,26 +258,26 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
)}
{/* Result */}
{result !== null && (
{state.result !== null && (
<div className="rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
<h2 className="text-lg font-semibold text-stone-950">Import Result</h2>
<div className="mt-3 flex gap-6">
<div>
<p className="text-3xl font-bold text-emerald-600">{result.created}</p>
<p className="text-3xl font-bold text-emerald-600">{state.result.created}</p>
<p className="text-sm text-stone-500">Created</p>
</div>
<div>
<p className="text-3xl font-bold text-blue-600">{result.updated}</p>
<p className="text-3xl font-bold text-blue-600">{state.result.updated}</p>
<p className="text-sm text-stone-500">Updated</p>
</div>
<div>
<p className="text-3xl font-bold text-red-600">{result.errors.length}</p>
<p className="text-3xl font-bold text-red-600">{state.result.errors.length}</p>
<p className="text-sm text-stone-500">Errors</p>
</div>
</div>
{result.errors.length > 0 && (
{state.result.errors.length > 0 && (
<ul className="mt-3 space-y-1">
{result.errors.map((e, i) => (
{state.result.errors.map((e, i) => (
<li key={`${e.product}-${i}`} className="text-sm text-red-600">
{e.product && `${e.product}: `}{e.error}
</li>
+86 -36
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useReducer, useRef } from "react";
import Link from "next/link";
import { parseOrderCSV } from "@/lib/csv-parsers";
import { importOrdersBatch } from "@/actions/import-orders";
@@ -14,13 +14,63 @@ type PreviewRow = {
_rowIndex: number;
};
type ImportResult = {
imported: number;
errors: { row: number; error: string }[];
};
type State = {
csvText: string;
preview: PreviewRow[] | null;
parseErrors: { row: number; error: string }[];
importing: boolean;
result: ImportResult | null;
brandId: string;
};
type Action =
| { type: "SET_CSV_TEXT"; value: string }
| { type: "SET_BRAND_ID"; value: string }
| { type: "PARSE_SUCCESS"; rows: PreviewRow[]; errors: { row: number; error: string }[] }
| { type: "PARSE_FAIL"; error: string }
| { type: "START_IMPORT" }
| { type: "IMPORT_SUCCESS"; result: ImportResult }
| { type: "IMPORT_FAIL"; error: string };
const initialState: State = {
csvText: "",
preview: null,
parseErrors: [],
importing: false,
result: null,
brandId: "",
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CSV_TEXT":
return { ...state, csvText: action.value };
case "SET_BRAND_ID":
return { ...state, brandId: action.value };
case "PARSE_SUCCESS":
return { ...state, preview: action.rows, parseErrors: action.errors };
case "PARSE_FAIL":
return { ...state, preview: null, parseErrors: [{ row: 0, error: action.error }] };
case "START_IMPORT":
return { ...state, importing: true };
case "IMPORT_SUCCESS":
return { ...state, importing: false, result: action.result };
case "IMPORT_FAIL":
return {
...state,
importing: false,
result: { imported: 0, errors: [{ row: 0, error: action.error }] },
};
}
}
export default function SalesImportPage() {
const [csvText, setCsvText] = useState("");
const [preview, setPreview] = useState<PreviewRow[] | null>(null);
const [parseErrors, setParseErrors] = useState<{ row: number; error: string }[]>([]);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ imported: number; errors: { row: number; error: string }[] } | null>(null);
const [brandId, setBrandId] = useState("");
const [state, dispatch] = useReducer(reducer, initialState);
const fileRef = useRef<HTMLInputElement>(null);
const SAMPLE_CSV = `customer_name,customer_email,customer_phone,stop_id,product_id,quantity,fulfillment
@@ -34,33 +84,31 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
setCsvText(text);
dispatch({ type: "SET_CSV_TEXT", value: text });
handlePreview(text);
};
reader.readAsText(file);
}
async function handlePreview(text?: string) {
const toParse = text ?? csvText;
const toParse = text ?? state.csvText;
if (!toParse.trim()) return;
const parseResult = await parseOrderCSV(toParse);
if (!parseResult.success) {
setPreview(null);
setParseErrors([{ row: 0, error: parseResult.error }]);
dispatch({ type: "PARSE_FAIL", error: parseResult.error });
return;
}
setPreview(parseResult.rows);
setParseErrors(parseResult.errors);
dispatch({ type: "PARSE_SUCCESS", rows: parseResult.rows, errors: parseResult.errors });
}
async function handleImport() {
if (!preview || !brandId) return;
setImporting(true);
if (!state.preview || !state.brandId) return;
dispatch({ type: "START_IMPORT" });
const importResult = await importOrdersBatch(
brandId,
preview.map((r) => ({
state.brandId,
state.preview.map((r) => ({
customer_name: r.customer_name,
customer_email: r.customer_email,
customer_phone: r.customer_phone,
@@ -68,11 +116,13 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
items: r.items,
}))
);
setImporting(false);
if (importResult.success) {
setResult({ imported: importResult.imported, errors: importResult.errors });
dispatch({
type: "IMPORT_SUCCESS",
result: { imported: importResult.imported, errors: importResult.errors },
});
} else {
setResult({ imported: 0, errors: [{ row: 0, error: importResult.error }] });
dispatch({ type: "IMPORT_FAIL", error: importResult.error });
}
}
@@ -94,8 +144,8 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
<input aria-label="64294306 5f42 463d A5e8 2ad6c81a96de"
id="import-orders-brand"
type="text"
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
value={state.brandId}
onChange={(e) => dispatch({ type: "SET_BRAND_ID", value: e.target.value })}
placeholder="64294306-5f42-463d-a5e8-2ad6c81a96de"
className="mt-1 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-blue-500 bg-white"
/>
@@ -108,8 +158,8 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
<label htmlFor="import-orders-text" className="sr-only">CSV text</label>
<textarea aria-label="Import Orders Text"
id="import-orders-text"
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
value={state.csvText}
onChange={(e) => dispatch({ type: "SET_CSV_TEXT", value: e.target.value })}
rows={6}
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500 bg-white"
/>
@@ -121,27 +171,27 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
</button>
</div>
{parseErrors.length > 0 && (
{state.parseErrors.length > 0 && (
<div className="mb-4 rounded-xl bg-red-50 p-4 border border-red-200">
<p className="text-sm font-semibold text-red-600">Parse Errors</p>
<ul className="mt-1 space-y-1">
{parseErrors.map((e) => (
{state.parseErrors.map((e) => (
<li key={e.row} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
))}
</ul>
</div>
)}
{preview !== null && (
{state.preview !== null && (
<div className="mb-4 card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-stone-950">Preview ({preview.length} rows)</h2>
<h2 className="text-lg font-semibold text-stone-950">Preview ({state.preview.length} rows)</h2>
<button type="button"
onClick={handleImport}
disabled={!brandId || importing}
disabled={!state.brandId || state.importing}
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-700"
>
{importing ? "Importing..." : `Import ${preview.length} Orders`}
{state.importing ? "Importing..." : `Import ${state.preview.length} Orders`}
</button>
</div>
<div className="overflow-x-auto">
@@ -155,7 +205,7 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
</tr>
</thead>
<tbody>
{preview.map((row) => (
{state.preview.map((row) => (
<tr key={row._rowIndex} className="border-b border-stone-200">
<td className="py-2 px-3 font-medium text-stone-950">{row.customer_name}</td>
<td className="py-2 px-3 text-stone-600">{row.customer_email}</td>
@@ -171,16 +221,16 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
</div>
)}
{result !== null && (
{state.result !== null && (
<div className="card p-6">
<h2 className="text-lg font-semibold text-stone-950">Import Result</h2>
<div className="mt-3">
<p className="text-3xl font-bold text-emerald-600">{result.imported}</p>
<p className="text-3xl font-bold text-emerald-600">{state.result.imported}</p>
<p className="text-sm text-stone-500">Orders imported</p>
</div>
{result.errors.length > 0 && (
{state.result.errors.length > 0 && (
<ul className="mt-3 space-y-1">
{result.errors.map((e, i) => (
{state.result.errors.map((e, i) => (
<li key={`row-${e.row}-${i}`} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
))}
</ul>
@@ -190,4 +240,4 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
</div>
</main>
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,18 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { useReducer, useEffect } from "react";
import AIProviderPanel from "@/components/admin/AIProviderPanel";
import { savePaymentSettings } from "@/actions/payments";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
import {
saveResendCredentials,
saveTwilioCredentials,
testResendConnection,
testTwilioConnection,
getResendCredentials,
getTwilioCredentials,
} from "@/actions/integrations/credentials";
import { AdminInput, AdminTextInput, AdminSelect, AdminButton } from "@/components/admin/design-system";
import { AdminToggle } from "@/components/admin/design-system/AdminToggle";
type Props = {
brandId: string;
@@ -31,31 +37,8 @@ type Integration = {
connected?: boolean;
};
// Simplified integration cards for display (AI is handled separately)
const COMMUNICATION_INTEGRATIONS: { id: string; name: string; icon: string; description: string; accentColor: string }[] = [
{
id: "resend",
name: "Resend",
icon: "📧",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "border-amber-200 bg-amber-50/50",
},
{
id: "stripe",
name: "Stripe",
icon: "💳",
description: "Process online payments for orders.",
accentColor: "border-stone-200 bg-stone-50/50",
},
{
id: "twilio",
name: "Twilio",
icon: "📱",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "border-blue-200 bg-blue-50/50",
},
];
// Integration cards configured below. AI is handled by AIProviderPanel
// and Stripe/Resend/Twilio each render their own IntegrationCard.
const INTEGRATIONS: Integration[] = [
{
id: "openai",
@@ -106,6 +89,52 @@ const INTEGRATIONS_WITHOUT_OPENAI: Integration[] = INTEGRATIONS.filter(
(i) => i.id !== "openai"
);
type CardState = {
credentials: Record<string, string>;
showSecrets: Record<string, boolean>;
testResult: { ok: boolean; message: string } | null;
saving: boolean;
saved: boolean;
error: string | null;
};
type CardAction =
| { type: "SET_CREDENTIAL"; key: string; value: string }
| { type: "TOGGLE_SECRET"; key: string }
| { type: "SET_TEST_RESULT"; value: { ok: boolean; message: string } | null }
| { type: "SET_SAVING"; value: boolean }
| { type: "SET_SAVED"; value: boolean }
| { type: "SET_ERROR"; value: string | null }
| { type: "CLEAR_TEST_AND_ERROR" };
const initialCardState: CardState = {
credentials: {},
showSecrets: {},
testResult: null,
saving: false,
saved: false,
error: null,
};
function cardReducer(state: CardState, action: CardAction): CardState {
switch (action.type) {
case "SET_CREDENTIAL":
return { ...state, credentials: { ...state.credentials, [action.key]: action.value } };
case "TOGGLE_SECRET":
return { ...state, showSecrets: { ...state.showSecrets, [action.key]: !state.showSecrets[action.key] } };
case "SET_TEST_RESULT":
return { ...state, testResult: action.value };
case "SET_SAVING":
return { ...state, saving: action.value };
case "SET_SAVED":
return { ...state, saved: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "CLEAR_TEST_AND_ERROR":
return { ...state, testResult: null, error: null };
}
}
function IntegrationCard({
integration,
initialCredentials,
@@ -115,141 +144,173 @@ function IntegrationCard({
initialCredentials?: Record<string, string>;
brandId: string;
}) {
const [credentials, setCredentials] = useState<Record<string, string>>(
initialCredentials ?? {}
);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [state, dispatch] = useReducer(cardReducer, {
...initialCardState,
credentials: initialCredentials ?? {},
});
// Derived from `credentials` + `initialCredentials` during render so we
// don't pay for an extra commit and stale-frame window.
const hasValues = Object.values(credentials).some((v) => v.trim().length > 0);
const dirty = hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials);
const hasValues = Object.values(state.credentials).some((v) => v.trim().length > 0);
const dirty = hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(state.credentials);
async function handleTest() {
setTestResult(null);
setError(null);
dispatch({ type: "CLEAR_TEST_AND_ERROR" });
if (integration.id === "resend") {
const apiKey = credentials["RESEND_API_KEY"];
const apiKey = state.credentials["RESEND_API_KEY"];
if (!apiKey?.trim()) {
setTestResult({ ok: false, message: "API key is required" });
dispatch({ type: "SET_TEST_RESULT", value: { ok: false, message: "API key is required" } });
return;
}
const result = await testResendConnection(apiKey);
setTestResult(result);
dispatch({ type: "SET_TEST_RESULT", value: result });
} else if (integration.id === "twilio") {
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
const authToken = credentials["TWILIO_AUTH_TOKEN"];
const accountSid = state.credentials["TWILIO_ACCOUNT_SID"];
const authToken = state.credentials["TWILIO_AUTH_TOKEN"];
if (!accountSid?.trim() || !authToken?.trim()) {
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
dispatch({ type: "SET_TEST_RESULT", value: { ok: false, message: "Account SID and Auth Token are required" } });
return;
}
const result = await testTwilioConnection(accountSid, authToken);
setTestResult(result);
dispatch({ type: "SET_TEST_RESULT", value: result });
} else {
// Default test (placeholder)
await new Promise((r) => setTimeout(r, 500));
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
setTestResult(
hasKey
const hasKey = Object.values(state.credentials).some((v) => v.trim().length > 0);
dispatch({
type: "SET_TEST_RESULT",
value: hasKey
? { ok: true, message: `Successfully connected to ${integration.name}` }
: { ok: false, message: `No API key configured for ${integration.name}` }
);
: { ok: false, message: `No API key configured for ${integration.name}` },
});
}
}
async function handleSave() {
setSaving(true);
setError(null);
setTestResult(null);
dispatch({ type: "SET_SAVING", value: true });
dispatch({ type: "CLEAR_TEST_AND_ERROR" });
try {
if (integration.id === "resend") {
const result = await saveResendCredentials(brandId, {
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
api_key: state.credentials["RESEND_API_KEY"]?.trim() || null,
from_email: state.credentials["RESEND_FROM_EMAIL"]?.trim() || null,
from_name: state.credentials["RESEND_FROM_NAME"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
return;
}
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
dispatch({ type: "SET_TEST_RESULT", value: { ok: true, message: "Resend credentials saved successfully" } });
dispatch({ type: "SET_SAVED", value: true });
window.setTimeout(() => dispatch({ type: "SET_SAVED", value: false }), 3000);
} else if (integration.id === "twilio") {
const result = await saveTwilioCredentials(brandId, {
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
account_sid: state.credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
auth_token: state.credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
phone_number: state.credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
return;
}
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
dispatch({ type: "SET_TEST_RESULT", value: { ok: true, message: "Twilio credentials saved successfully" } });
dispatch({ type: "SET_SAVED", value: true });
window.setTimeout(() => dispatch({ type: "SET_SAVED", value: false }), 3000);
} else if (integration.id === "stripe") {
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
stripePublishableKey: state.credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: state.credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
return;
}
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
dispatch({ type: "SET_TEST_RESULT", value: { ok: true, message: "Stripe credentials saved successfully" } });
dispatch({ type: "SET_SAVED", value: true });
window.setTimeout(() => dispatch({ type: "SET_SAVED", value: false }), 3000);
} else {
// Other integrations - just show success for now
setTestResult({ ok: true, message: `${integration.name} settings saved` });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
dispatch({ type: "SET_TEST_RESULT", value: { ok: true, message: `${integration.name} settings saved` } });
dispatch({ type: "SET_SAVED", value: true });
window.setTimeout(() => dispatch({ type: "SET_SAVED", value: false }), 3000);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
dispatch({ type: "SET_ERROR", value: err instanceof Error ? err.message : "Failed to save settings" });
} finally {
setSaving(false);
dispatch({ type: "SET_SAVING", value: false });
}
}
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
function updateCredential(key: string, value: string) {
setCredentials((prev) => ({ ...prev, [key]: value }));
}
return (
<div className={`rounded-2xl border p-6 bg-white ${integration.accentColor}`}>
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-bg)] text-lg">
{integration.icon}
</div>
<div>
<h3 className="text-base font-semibold text-[var(--admin-text-primary)]">{integration.name}</h3>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">{integration.description}</p>
</div>
</div>
{testResult?.ok && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 px-3 py-1 text-xs font-medium text-[var(--admin-success)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
Connected
</span>
)}
</div>
<IntegrationHeader name={integration.name} icon={integration.icon} description={integration.description} ok={state.testResult?.ok ?? false} />
{/* Messages */}
<StatusBanners error={state.error} saved={state.saved} testResult={state.testResult} />
<CredentialsForm
credentials={integration.credentials}
values={state.credentials}
showSecrets={state.showSecrets}
onChange={(key, value) => dispatch({ type: "SET_CREDENTIAL", key, value })}
onToggleSecret={(key) => dispatch({ type: "TOGGLE_SECRET", key })}
/>
<ActionButtons
saving={state.saving}
dirty={dirty}
ok={state.testResult?.ok ?? false}
onTest={handleTest}
onSave={handleSave}
/>
</div>
);
}
function IntegrationHeader({
name,
icon,
description,
ok,
}: {
name: string;
icon: string;
description: string;
ok: boolean;
}) {
return (
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-bg)] text-lg">
{icon}
</div>
<div>
<h3 className="text-base font-semibold text-[var(--admin-text-primary)]">{name}</h3>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">{description}</p>
</div>
</div>
{ok && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 px-3 py-1 text-xs font-medium text-[var(--admin-success)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
Connected
</span>
)}
</div>
);
}
type StatusBannersProps = {
error: string | null;
saved: boolean;
testResult: { ok: boolean; message: string } | null;
};
function StatusBanners({ error, saved, testResult }: StatusBannersProps) {
return (
<>
{error && (
<div className="mb-4 rounded-xl p-4 text-sm border flex items-start gap-3 bg-[var(--admin-danger)]/10 border-[var(--admin-danger)]/30 text-[var(--admin-danger)]">
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -268,8 +329,8 @@ function IntegrationCard({
)}
{testResult && (
<div className={`mb-4 rounded-xl p-4 text-sm border flex items-start gap-3 ${
testResult.ok
? "bg-[var(--admin-success)]/10 border-[var(--admin-success)]/30 text-[var(--admin-success)]"
testResult.ok
? "bg-[var(--admin-success)]/10 border-[var(--admin-success)]/30 text-[var(--admin-success)]"
: "bg-[var(--admin-danger)]/10 border-[var(--admin-danger)]/30 text-[var(--admin-danger)]"
}`}>
{testResult.ok ? (
@@ -284,110 +345,161 @@ function IntegrationCard({
{testResult.message}
</div>
)}
</>
);
}
{/* Credentials form */}
<div className="space-y-4 mb-5">
{integration.credentials.map((field) => (
<AdminInput
key={field.key}
label={field.label}
helpText={field.isSecret ? "Click Show/Hide to reveal" : undefined}
>
<div className="relative">
<AdminTextInput
type={field.isSecret && !showSecrets[field.key] ? "password" : "text"}
value={credentials[field.key] ?? ""}
onChange={(e) => updateCredential(field.key, e.target.value)}
placeholder={field.placeholder}
/>
{field.isSecret && (
<button
type="button"
onClick={() => toggleSecret(field.key)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</AdminInput>
))}
</div>
type CredentialsFormProps = {
credentials: CredentialField[];
values: Record<string, string>;
showSecrets: Record<string, boolean>;
onChange: (key: string, value: string) => void;
onToggleSecret: (key: string) => void;
};
{/* Action buttons */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border)]">
<AdminButton
variant="secondary"
size="sm"
onClick={handleTest}
disabled={saving}
function CredentialsForm({ credentials, values, showSecrets, onChange, onToggleSecret }: CredentialsFormProps) {
return (
<div className="space-y-4 mb-5">
{credentials.map((field) => (
<AdminInput
key={field.key}
label={field.label}
helpText={field.isSecret ? "Click Show/Hide to reveal" : undefined}
>
{testResult?.ok ? "Re-test" : "Test Connection"}
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={handleSave}
disabled={saving || !dirty}
>
{saving ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
Saving...
</span>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Save
</>
)}
</AdminButton>
{dirty && !saving && (
<span className="text-xs text-[var(--admin-text-muted)]">Unsaved changes</span>
)}
</div>
<div className="relative">
<AdminTextInput
type={field.isSecret && !showSecrets[field.key] ? "password" : "text"}
value={values[field.key] ?? ""}
onChange={(e) => onChange(field.key, e.target.value)}
placeholder={field.placeholder}
/>
{field.isSecret && (
<button
type="button"
onClick={() => onToggleSecret(field.key)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</AdminInput>
))}
</div>
);
}
type ActionButtonsProps = {
saving: boolean;
dirty: boolean;
ok: boolean;
onTest: () => void;
onSave: () => void;
};
function ActionButtons({ saving, dirty, ok, onTest, onSave }: ActionButtonsProps) {
return (
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border)]">
<AdminButton
variant="secondary"
size="sm"
onClick={onTest}
disabled={saving}
>
{ok ? "Re-test" : "Test Connection"}
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={onSave}
disabled={saving || !dirty}
>
{saving ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
Saving...
</span>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Save
</>
)}
</AdminButton>
{dirty && !saving && (
<span className="text-xs text-[var(--admin-text-muted)]">Unsaved changes</span>
)}
</div>
);
}
type PageState = {
selectedBrandId: string;
initialCredentials: Record<string, Record<string, string>>;
loading: boolean;
};
type PageAction =
| { type: "SET_SELECTED_BRAND"; value: string }
| { type: "SET_INITIAL_CREDENTIALS"; value: Record<string, Record<string, string>> }
| { type: "SET_LOADING"; value: boolean };
const initialPageState: PageState = {
selectedBrandId: "",
initialCredentials: {},
loading: true,
};
function pageReducer(state: PageState, action: PageAction): PageState {
switch (action.type) {
case "SET_SELECTED_BRAND":
return { ...state, selectedBrandId: action.value };
case "SET_INITIAL_CREDENTIALS":
return { ...state, initialCredentials: action.value };
case "SET_LOADING":
return { ...state, loading: action.value };
}
}
export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmin }: Props) {
// `selectedBrandId` is the platform admin's explicit brand selection. The
// initial value is not derived from a prop so the value stays in sync with
// the server-truth `brandId` once a user makes a choice: we always render
// off `effectiveBrandId = selectedBrandId || brandId`, so any change to
// `brandId` (e.g., route nav) is reflected until the user picks another.
const [selectedBrandId, setSelectedBrandId] = useState<string>("");
const effectiveBrandId = selectedBrandId || brandId;
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(true);
const [state, dispatch] = useReducer(pageReducer, initialPageState);
const effectiveBrandId = state.selectedBrandId || brandId;
// Fetch initial credentials for each integration
useEffect(() => {
async function fetchCredentials() {
setLoading(true);
dispatch({ type: "SET_LOADING", value: true });
try {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(effectiveBrandId),
getTwilioCredentials(effectiveBrandId),
]);
setInitialCredentials({
resend: {
RESEND_API_KEY: resendCreds.api_key ?? "",
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
RESEND_FROM_NAME: resendCreds.from_name ?? "",
},
twilio: {
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
dispatch({
type: "SET_INITIAL_CREDENTIALS",
value: {
resend: {
RESEND_API_KEY: resendCreds.api_key ?? "",
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
RESEND_FROM_NAME: resendCreds.from_name ?? "",
},
twilio: {
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
},
},
});
} finally {
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
}
}
fetchCredentials();
@@ -395,78 +507,119 @@ export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmi
return (
<div className="space-y-8 max-w-4xl">
{/* Brand selector for platform admins */}
{isPlatformAdmin && brands.length > 0 && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-5">
<AdminInput label="Select Brand" helpText="Choose a brand to configure integrations for:">
<AdminSelect
value={effectiveBrandId}
onChange={(e) => setSelectedBrandId(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
</div>
)}
<BrandSelector
isPlatformAdmin={isPlatformAdmin}
brands={brands}
effectiveBrandId={effectiveBrandId}
onSelectBrand={(value) => dispatch({ type: "SET_SELECTED_BRAND", value })}
/>
{/* AI Provider Section */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-700 text-sm">🤖</div>
<div>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
<p className="text-xs text-[var(--admin-text-muted)]">Configure AI models for intelligent features</p>
</div>
</div>
<Link href="/admin/settings/ai" className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
Configure AI
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</Link>
</div>
<div className="p-5">
<AIProviderPanel brandId={effectiveBrandId} />
</div>
</div>
<AIProviderSection brandId={effectiveBrandId} />
{/* Payment & Email integrations grid */}
<div>
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Communication & Payments</h2>
<div className="space-y-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Loading integrations...</span>
</div>
</div>
) : (
INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
initialCredentials={initialCredentials[integration.id]}
brandId={effectiveBrandId}
/>
))
)}
</div>
</div>
<IntegrationsList
loading={state.loading}
initialCredentials={state.initialCredentials}
effectiveBrandId={effectiveBrandId}
/>
{/* Help text */}
<div className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-[var(--admin-text-muted)] shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
<HelpFooter />
</div>
);
}
type BrandSelectorProps = {
isPlatformAdmin: boolean;
brands: { id: string; name: string }[];
effectiveBrandId: string;
onSelectBrand: (value: string) => void;
};
function BrandSelector({ isPlatformAdmin, brands, effectiveBrandId, onSelectBrand }: BrandSelectorProps) {
if (!isPlatformAdmin || brands.length === 0) return null;
return (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-5">
<AdminInput label="Select Brand" helpText="Choose a brand to configure integrations for:">
<AdminSelect
value={effectiveBrandId}
onChange={(e) => onSelectBrand(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
</div>
);
}
function AIProviderSection({ brandId }: { brandId: string }) {
return (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-700 text-sm">🤖</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Need help?</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
Contact us at <a href="mailto:support@cielohermosa.com" className="underline hover:text-[var(--admin-accent)]">support@cielohermosa.com</a> for assistance setting up integrations.
</p>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
<p className="text-xs text-[var(--admin-text-muted)]">Configure AI models for intelligent features</p>
</div>
</div>
<Link href="/admin/settings/ai" className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
Configure AI
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</Link>
</div>
<div className="p-5">
<AIProviderPanel brandId={brandId} />
</div>
</div>
);
}
type IntegrationsListProps = {
loading: boolean;
initialCredentials: Record<string, Record<string, string>>;
effectiveBrandId: string;
};
function IntegrationsList({ loading, initialCredentials, effectiveBrandId }: IntegrationsListProps) {
return (
<div>
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Communication &amp; Payments</h2>
<div className="space-y-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Loading integrations...</span>
</div>
</div>
) : (
INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
initialCredentials={initialCredentials[integration.id]}
brandId={effectiveBrandId}
/>
))
)}
</div>
</div>
);
}
function HelpFooter() {
return (
<div className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-[var(--admin-text-muted)] shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Need help?</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
Contact us at <a href="mailto:support@cielohermosa.com" className="underline hover:text-[var(--admin-accent)]">support@cielohermosa.com</a> for assistance setting up integrations.
</p>
</div>
</div>
</div>
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+180 -50
View File
@@ -15,7 +15,7 @@
* a focused "form" feel.
*/
import { useState, useEffect, useCallback } from "react";
import { useReducer, useEffect, useCallback } from "react";
import {
getWaterAdminSettings,
saveWaterAdminSettings,
@@ -25,50 +25,166 @@ import {
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default function WaterLogSettingsPage() {
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [revealedPin, setRevealedPin] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
type Message = { type: "success" | "error"; text: string } | null;
// Form state
const [enabled, setEnabled] = useState(false);
const [sessionDuration, setSessionDuration] = useState(12);
const [canEdit, setCanEdit] = useState(true);
const [canDelete, setCanDelete] = useState(true);
const [canExport, setCanExport] = useState(true);
const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false);
type State = {
settings: AdminSettings | null;
loading: boolean;
saving: boolean;
regenerating: boolean;
revealedPin: string | null;
message: Message;
enabled: boolean;
sessionDuration: number;
canEdit: boolean;
canDelete: boolean;
canExport: boolean;
alertPhone: string;
alertsEnabled: boolean;
};
type Action =
| { type: "LOAD_START" }
| {
type: "LOAD_SUCCESS";
data: AdminSettings;
}
| { type: "LOAD_DONE" }
| { type: "SAVE_START" }
| { type: "SAVE_SUCCESS"; settings: AdminSettings | undefined }
| { type: "SAVE_FAIL"; message: Message }
| { type: "SAVE_DONE" }
| { type: "REGEN_START" }
| { type: "REGEN_SUCCESS"; pin: string; message: Message }
| { type: "REGEN_FAIL"; message: Message }
| { type: "REGEN_DONE" }
| { type: "DISMISS_PIN" }
| { type: "CLEAR_MESSAGE" }
| { type: "SET_ENABLED"; value: boolean }
| { type: "SET_SESSION_DURATION"; value: number }
| { type: "SET_CAN_EDIT"; value: boolean }
| { type: "SET_CAN_DELETE"; value: boolean }
| { type: "SET_CAN_EXPORT"; value: boolean }
| { type: "SET_ALERT_PHONE"; value: string }
| { type: "SET_ALERTS_ENABLED"; value: boolean };
const initialState: State = {
settings: null,
loading: true,
saving: false,
regenerating: false,
revealedPin: null,
message: null,
enabled: false,
sessionDuration: 12,
canEdit: true,
canDelete: true,
canExport: true,
alertPhone: "",
alertsEnabled: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "LOAD_START":
return { ...state, loading: true };
case "LOAD_SUCCESS":
return {
...state,
settings: action.data,
enabled: action.data.enabled,
sessionDuration: action.data.sessionDurationHours,
canEdit: action.data.canEditEntries,
canDelete: action.data.canDeleteEntries,
canExport: action.data.canExportCsv,
alertPhone: action.data.alertPhone ?? "",
alertsEnabled: action.data.alertsEnabled,
};
case "LOAD_DONE":
return { ...state, loading: false };
case "SAVE_START":
return { ...state, saving: true, message: null };
case "SAVE_SUCCESS":
return {
...state,
message: { type: "success", text: "Settings saved" },
settings: action.settings ?? state.settings,
};
case "SAVE_FAIL":
return { ...state, message: action.message };
case "SAVE_DONE":
return { ...state, saving: false };
case "REGEN_START":
return { ...state, regenerating: true, message: null };
case "REGEN_SUCCESS":
return {
...state,
revealedPin: action.pin,
message: action.message,
};
case "REGEN_FAIL":
return { ...state, message: action.message };
case "REGEN_DONE":
return { ...state, regenerating: false };
case "DISMISS_PIN":
return { ...state, revealedPin: null };
case "CLEAR_MESSAGE":
return { ...state, message: null };
case "SET_ENABLED":
return { ...state, enabled: action.value };
case "SET_SESSION_DURATION":
return { ...state, sessionDuration: action.value };
case "SET_CAN_EDIT":
return { ...state, canEdit: action.value };
case "SET_CAN_DELETE":
return { ...state, canDelete: action.value };
case "SET_CAN_EXPORT":
return { ...state, canExport: action.value };
case "SET_ALERT_PHONE":
return { ...state, alertPhone: action.value };
case "SET_ALERTS_ENABLED":
return { ...state, alertsEnabled: action.value };
default:
return state;
}
}
export default function WaterLogSettingsPage() {
const [state, dispatch] = useReducer(reducer, initialState);
const {
settings,
loading,
saving,
regenerating,
revealedPin,
message,
enabled,
sessionDuration,
canEdit,
canDelete,
canExport,
alertPhone,
alertsEnabled,
} = state;
const loadSettings = useCallback(async () => {
setLoading(true);
dispatch({ type: "LOAD_START" });
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
if (data) {
setSettings(data);
setEnabled(data.enabled);
setSessionDuration(data.sessionDurationHours);
setCanEdit(data.canEditEntries);
setCanDelete(data.canDeleteEntries);
setCanExport(data.canExportCsv);
setAlertPhone(data.alertPhone ?? "");
setAlertsEnabled(data.alertsEnabled);
dispatch({ type: "LOAD_SUCCESS", data });
}
setLoading(false);
dispatch({ type: "LOAD_DONE" });
}, []);
useEffect(() => {
// Load on mount — setState-in-effect is intentional here
// because we're hydrating from server data.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadSettings();
}, [loadSettings]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMessage(null);
dispatch({ type: "SAVE_START" });
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
enabled,
sessionDurationHours: sessionDuration,
@@ -79,31 +195,38 @@ export default function WaterLogSettingsPage() {
alertsEnabled,
});
if (result.success) {
setMessage({ type: "success", text: "Settings saved" });
if (result.settings) setSettings(result.settings);
dispatch({ type: "SAVE_SUCCESS", settings: result.settings });
} else {
setMessage({ type: "error", text: result.error ?? "Save failed" });
dispatch({
type: "SAVE_FAIL",
message: { type: "error", text: result.error ?? "Save failed" },
});
}
setSaving(false);
dispatch({ type: "SAVE_DONE" });
}
async function handleRegenerate() {
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
return;
}
setRegenerating(true);
setMessage(null);
dispatch({ type: "REGEN_START" });
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
if (result.success && result.pin) {
setRevealedPin(result.pin);
setMessage({
type: "success",
text: "New PIN generated. Save it now — it will not be shown again.",
dispatch({
type: "REGEN_SUCCESS",
pin: result.pin,
message: {
type: "success",
text: "New PIN generated. Save it now — it will not be shown again.",
},
});
} else {
setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" });
dispatch({
type: "REGEN_FAIL",
message: { type: "error", text: result.error ?? "Failed to regenerate PIN" },
});
}
setRegenerating(false);
dispatch({ type: "REGEN_DONE" });
}
if (loading) {
@@ -153,7 +276,7 @@ export default function WaterLogSettingsPage() {
label="Enable"
description="Allow water admins to sign in with a 4-digit PIN."
value={enabled}
onChange={setEnabled}
onChange={(value) => dispatch({ type: "SET_ENABLED", value })}
/>
</Card>
@@ -176,7 +299,7 @@ export default function WaterLogSettingsPage() {
</div>
<button
type="button"
onClick={() => setRevealedPin(null)}
onClick={() => dispatch({ type: "DISMISS_PIN" })}
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
>
I&apos;ve saved it dismiss
@@ -209,7 +332,12 @@ export default function WaterLogSettingsPage() {
min={1}
max={168}
value={sessionDuration}
onChange={(e) => setSessionDuration(parseInt(e.target.value, 10))}
onChange={(e) =>
dispatch({
type: "SET_SESSION_DURATION",
value: parseInt(e.target.value, 10),
})
}
className="flex-1 accent-[#1a4d2e]"
aria-label="Session duration in hours"
/>
@@ -226,19 +354,19 @@ export default function WaterLogSettingsPage() {
label="Edit entries"
description="Allow modifying existing log entries."
value={canEdit}
onChange={setCanEdit}
onChange={(value) => dispatch({ type: "SET_CAN_EDIT", value })}
/>
<ToggleRow
label="Delete entries"
description="Allow removing log entries."
value={canDelete}
onChange={setCanDelete}
onChange={(value) => dispatch({ type: "SET_CAN_DELETE", value })}
/>
<ToggleRow
label="Export CSV"
description="Allow exporting water log data."
value={canExport}
onChange={setCanExport}
onChange={(value) => dispatch({ type: "SET_CAN_EXPORT", value })}
/>
</div>
</Card>
@@ -249,7 +377,7 @@ export default function WaterLogSettingsPage() {
label="Enable alerts"
description="Threshold breach notifications."
value={alertsEnabled}
onChange={setAlertsEnabled}
onChange={(value) => dispatch({ type: "SET_ALERTS_ENABLED", value })}
/>
{alertsEnabled && (
<div className="mt-4">
@@ -260,7 +388,9 @@ export default function WaterLogSettingsPage() {
id="water-alert-phone"
type="tel"
value={alertPhone}
onChange={(e) => setAlertPhone(e.target.value)}
onChange={(e) =>
dispatch({ type: "SET_ALERT_PHONE", value: e.target.value })
}
placeholder="+13035551234"
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
autoComplete="tel"
@@ -342,4 +472,4 @@ function ToggleRow({
</button>
</div>
);
}
}
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
function isValidEmail(e: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
}
export default function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => void }) {
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
function handleAdd() {
if (!email.trim()) return;
if (!isValidEmail(email.trim())) {
setError("Enter a valid email address.");
return;
}
setError("");
onAdd(email.trim(), name.trim());
setEmail("");
setName("");
}
return (
<div className="flex gap-2 items-start">
<div className="flex-1">
<input aria-label="Email Address"
type="email" placeholder="Email address"
value={email} onChange={e => { setEmail(e.target.value); setError(""); }}
onKeyDown={e => e.key === "Enter" && handleAdd()}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="w-36">
<input aria-label="Name (optional)"
type="text" placeholder="Name (optional)"
value={name} onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleAdd()}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<AdminButton
variant="primary"
size="sm"
onClick={handleAdd}
>
Add
</AdminButton>
{error && <p className="text-xs text-[var(--admin-danger)] mt-1">{error}</p>}
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import {
type WholesaleCustomer,
} from "@/actions/wholesale";
import { AdminButton } from "@/components/admin/design-system";
import { type CustomerForm } from "./customerFormState";
type CustomerFormSectionProps = {
editing: WholesaleCustomer | null;
form: CustomerForm;
saving: boolean;
onFieldChange: <K extends keyof CustomerForm>(field: K, value: CustomerForm[K]) => void;
onSave: () => void;
onCancel: () => void;
};
export default function CustomerFormSection({ editing, form, saving, onFieldChange, onSave, onCancel }: CustomerFormSectionProps) {
return (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Customer" : "New Customer"}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="ws-company-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label>
<input aria-label="Ws Company Name" id="ws-company-name" value={form.companyName} onChange={e => onFieldChange("companyName", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="ws-contact-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label>
<input aria-label="Ws Contact Name" id="ws-contact-name" value={form.contactName} onChange={e => onFieldChange("contactName", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="ws-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label>
<input aria-label="Ws Email" id="ws-email" type="email" value={form.email} onChange={e => onFieldChange("email", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="email" />
</div>
<div>
<label htmlFor="ws-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label>
<input aria-label="Ws Phone" id="ws-phone" type="tel" value={form.phone} onChange={e => onFieldChange("phone", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="tel" />
</div>
<div>
<label htmlFor="ws-account-status" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label>
<select aria-label="Ws Account Status" id="ws-account-status" value={form.accountStatus} onChange={e => onFieldChange("accountStatus", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="active">Active</option>
<option value="on_hold">On Hold</option>
<option value="disabled">Disabled</option>
<option value="pending_approval">Pending Approval</option>
</select>
</div>
<div>
<label htmlFor="ws-credit-limit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label>
<input aria-label="0 = Unlimited" id="ws-credit-limit" type="number" value={form.creditLimit} onChange={e => onFieldChange("creditLimit", Number(e.target.value))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0 = unlimited" />
</div>
</div>
<div className="mt-4 p-4 rounded-xl bg-[var(--admin-bg-subtle)] space-y-3">
<p className="text-sm font-semibold text-[var(--admin-text-secondary)]">Deposit Rules</p>
<div className="flex items-center gap-3">
<input aria-label="Dep Enabled" type="checkbox" checked={form.depositsEnabled}
onChange={e => onFieldChange("depositsEnabled", e.target.checked)}
className="rounded" id="dep-enabled" />
<label htmlFor="dep-enabled" className="text-sm text-[var(--admin-text-secondary)]">Enable deposit requirement</label>
</div>
{form.depositsEnabled && (
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="ws-deposit-threshold" className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label>
<input aria-label="0" id="ws-deposit-threshold" type="number" value={form.depositThreshold} onChange={e => onFieldChange("depositThreshold", e.target.value)}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" />
</div>
<div>
<label htmlFor="ws-deposit-pct" className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label>
<input aria-label="20" id="ws-deposit-pct" type="number" value={form.depositPercentage} onChange={e => onFieldChange("depositPercentage", e.target.value)}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" />
</div>
</div>
)}
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={onSave} disabled={saving} isLoading={saving} variant="primary">
{saving ? "Saving..." : "Save Customer"}
</AdminButton>
<AdminButton onClick={onCancel} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
"use client";
import {
type WholesaleCustomer,
} from "@/actions/wholesale";
import { formatDate } from "@/lib/format-date";
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
type CustomerListSectionProps = {
customers: WholesaleCustomer[];
selectedCustomers: Set<string>;
allCustomerIds: string[];
openCustomerActions: string | null;
deletingCustomer: string | null;
sendingPriceSheet: boolean;
onToggle: (id: string) => void;
onToggleAll: () => void;
onToggleActions: (id: string) => void;
onCloseActions: () => void;
onEdit: (c: WholesaleCustomer) => void;
onPricing: (c: WholesaleCustomer) => void;
onDelete: (id: string) => void;
onSendPriceSheet: (ids: string[]) => void;
};
export default function CustomerListSection({
customers,
selectedCustomers,
allCustomerIds,
openCustomerActions,
deletingCustomer,
sendingPriceSheet,
onToggle,
onToggleAll,
onToggleActions,
onCloseActions,
onEdit,
onPricing,
onDelete,
onSendPriceSheet,
}: CustomerListSectionProps) {
return (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">
<input aria-label="Checkbox" type="checkbox" className="rounded border-[var(--admin-border)]"
checked={selectedCustomers.size === customers.length && customers.length > 0}
onChange={onToggleAll}
/>
</th>
<th className="px-5 py-3 font-semibold text-left">Company</th>
<th className="px-5 py-3 font-semibold text-left">Contact</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Credit</th>
<th className="px-5 py-3 font-semibold text-left">Deposits</th>
<th className="px-5 py-3 font-semibold text-left">Created</th>
<th className="px-5 py-3" aria-label="Actions"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{customers.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No customers yet</p>
<p className="text-xs text-slate-400">Wholesale customers will appear here once registered.</p>
</div>
</td>
</tr>
) : customers.map(c => (
<CustomerRow
key={c.id}
customer={c}
isSelected={selectedCustomers.has(c.id)}
isActionsOpen={openCustomerActions === c.id}
isDeleting={deletingCustomer === c.id}
onToggle={() => onToggle(c.id)}
onToggleActions={() => onToggleActions(c.id)}
onCloseActions={onCloseActions}
onEdit={() => onEdit(c)}
onPricing={() => onPricing(c)}
onDelete={() => onDelete(c.id)}
onSendPriceSheet={() => onSendPriceSheet([c.id])}
/>
))}
</tbody>
{selectedCustomers.size > 0 && (
<tfoot className="bg-[var(--admin-accent-light)] border-t border-[var(--admin-accent)]">
<tr>
<td colSpan={8} className="px-5 py-3 flex items-center gap-4">
<span className="text-sm text-[var(--admin-accent-text)] font-medium">{selectedCustomers.size} selected</span>
<AdminButton
onClick={() => onSendPriceSheet(allCustomerIds.filter(id => selectedCustomers.has(id)))}
disabled={sendingPriceSheet}
variant="primary"
size="sm"
isLoading={sendingPriceSheet}
>
{sendingPriceSheet ? "Sending..." : `Send Price Sheet to ${selectedCustomers.size} Customer(s)`}
</AdminButton>
</td>
</tr>
</tfoot>
)}
</table>
</div>
);
}
type CustomerRowProps = {
customer: WholesaleCustomer;
isSelected: boolean;
isActionsOpen: boolean;
isDeleting: boolean;
onToggle: () => void;
onToggleActions: () => void;
onCloseActions: () => void;
onEdit: () => void;
onPricing: () => void;
onDelete: () => void;
onSendPriceSheet: () => void;
};
function CustomerRow({
customer: c,
isSelected,
isActionsOpen,
isDeleting,
onToggle,
onToggleActions,
onCloseActions,
onEdit,
onPricing,
onDelete,
onSendPriceSheet,
}: CustomerRowProps) {
return (
<tr className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3">
<input aria-label="Checkbox" type="checkbox" className="rounded border-[var(--admin-border)]"
checked={isSelected}
onChange={onToggle}
/>
</td>
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{c.company_name ?? "—"}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{c.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{c.email}</span></td>
<td className="px-5 py-3">
<AdminBadge variant={c.account_status === "active" ? "success" : c.account_status === "on_hold" ? "warning" : "danger"}>
{c.account_status}
</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">
{c.credit_limit <= 0 ? "Unlimited" : `$${Number(c.credit_limit).toFixed(2)}`}
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">
{c.deposits_enabled ? `${c.deposit_percentage}%` : "—"}
</td>
<td className="px-5 py-3 text-[var(--admin-text-muted)] text-xs">{formatDate(new Date(c.created_at))}</td>
<td className="px-5 py-4 customer-actions-cell relative">
<div className="flex items-center gap-1.5">
<button type="button"
onClick={onSendPriceSheet}
title="Send price sheet"
aria-label="Send price sheet"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-accent)] hover:text-[var(--admin-accent-text)] hover:bg-[var(--admin-accent-light)] transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
</button>
<div className="relative">
<button type="button"
onClick={onToggleActions}
title="More actions"
aria-label="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
{isActionsOpen && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-52 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { onCloseActions(); onEdit(); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
>
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit Customer
</button>
<button type="button"
onClick={() => { onCloseActions(); onPricing(); }}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Pricing
</button>
<a
href={`/wholesale/portal?preview_customer_id=${c.id}&_admin_preview=1`}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
View Portal As
</a>
<div className="my-1 border-t border-[var(--admin-border)]" />
<button type="button"
onClick={() => { onCloseActions(); onDelete(); }}
disabled={isDeleting}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{isDeleting ? "Deleting..." : "Delete Customer"}
</button>
</div>
)}
</div>
</div>
</td>
</tr>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useState, useEffect, useRef } from "react";
import {
type WholesaleCustomer,
type WholesaleProduct,
} from "@/actions/wholesale";
import { getWholesaleCustomerPricing, upsertWholesaleCustomerPricing, deleteWholesaleCustomerPricing } from "@/actions/wholesale-register";
import { AdminButton } from "@/components/admin/design-system";
export default function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
customer: WholesaleCustomer;
products: WholesaleProduct[];
onClose: () => void;
onMsg: (kind: "success" | "error", text: string) => void;
}) {
const [overrides, setOverrides] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving] = useState(false);
const dialogRef = useRef<HTMLDialogElement | null>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
useEffect(() => {
getWholesaleCustomerPricing(customer.id).then(pricing => {
const map: Record<string, string> = {};
for (const p of pricing) {
map[p.product_id] = p.custom_unit_price.toString();
}
setOverrides(map);
setLoading(false);
});
}, [customer.id]);
async function handleSave(productId: string, price: string) {
if (!price || isNaN(Number(price))) {
await deleteWholesaleCustomerPricing({ customerId: customer.id, productId });
setOverrides(prev => { const n = { ...prev }; delete n[productId]; return n; });
} else {
await upsertWholesaleCustomerPricing({ customerId: customer.id, productId, customUnitPrice: Number(price) });
setOverrides(prev => ({ ...prev, [productId]: price }));
}
onMsg("success", "Pricing updated.");
}
return (
<dialog
ref={dialogRef}
aria-label="Pricing overrides"
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col border border-[var(--admin-border)]">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Pricing Overrides</h2>
<p className="text-sm text-[var(--admin-text-muted)]">{customer.company_name}</p>
</div>
<button type="button" onClick={onClose} aria-label="Close pricing overrides" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-4">
{loading ? (
<p className="text-[var(--admin-text-muted)] text-sm">Loading...</p>
) : products.length === 0 ? (
<p className="text-[var(--admin-text-muted)] text-sm">No products available. Add wholesale products to see them here.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-2 font-medium">Product</th>
<th className="pb-2 font-medium">Standard Price</th>
<th className="pb-2 font-medium">Override Price</th>
<th className="pb-2" aria-label="Actions"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{products.map(p => {
const standardPrice = p.price_tiers?.[0]?.price ?? 0;
const overridePrice = overrides[p.id] ?? "";
return (
<tr key={p.id}>
<td className="py-2.5 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
<td className="py-2.5 text-[var(--admin-text-muted)]">${standardPrice.toFixed(2)}</td>
<td className="py-2.5">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--admin-text-muted)]">$</span>
<input aria-label="Number"
type="number"
step="0.01"
min="0"
value={overridePrice}
onChange={e => setOverrides(prev => ({ ...prev, [p.id]: e.target.value }))}
placeholder={standardPrice.toFixed(2)}
className="w-24 rounded-lg border border-[var(--admin-border)] px-2 py-1 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
{overridePrice && overridePrice !== standardPrice.toFixed(2) && (
<span className="text-xs text-[var(--admin-accent)] font-medium">Custom</span>
)}
</div>
</td>
<td className="py-2.5">
<AdminButton
variant="primary"
size="sm"
onClick={() => handleSave(p.id, overrides[p.id] ?? "")}
disabled={saving}
>
Save
</AdminButton>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<p className="mt-3 text-xs text-[var(--admin-text-muted)]">
Leave override price blank to remove custom pricing and use standard price tiers.
</p>
</div>
</div>
</dialog>
);
}
+404
View File
@@ -0,0 +1,404 @@
"use client";
import { useReducer, useEffect } from "react";
import {
type WholesaleCustomer,
type WholesaleProduct,
deleteWholesaleCustomer,
saveWholesaleCustomer,
} from "@/actions/wholesale";
import { approveWholesaleRegistration } from "@/actions/wholesale-register";
import { formatDate } from "@/lib/format-date";
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
import CustomerFormSection from "./CustomerForm";
import {
type CustomerForm,
EMPTY_CUSTOMER_FORM,
customerFormFromCustomer,
} from "./customerFormState";
import CustomerListSection from "./CustomerList";
import CustomerPricingPanel from "./CustomerPricingPanel";
import PriceSheetModal from "./PriceSheetModal";
type Registration = {
id: string; company_name: string | null; contact_name: string | null;
email: string; phone: string | null; account_status: string; created_at: string;
};
const EMPTY_REGISTRATIONS: Registration[] = [];
type State = {
showForm: boolean;
editing: WholesaleCustomer | null;
subTab: "customers" | "registrations";
form: CustomerForm;
saving: boolean;
processingReg: string | null;
pricingCustomer: WholesaleCustomer | null;
selectedCustomers: Set<string>;
sendingPriceSheet: boolean;
priceSheetTarget: { customerIds: string[]; defaultSubject: string } | null;
openCustomerActions: string | null;
deletingCustomer: string | null;
};
type Action =
| { type: "set_show_form"; showForm: boolean }
| { type: "set_sub_tab"; subTab: "customers" | "registrations" }
| { type: "set_form_field"; field: keyof CustomerForm; value: CustomerForm[keyof CustomerForm] }
| { type: "open_new" }
| { type: "open_edit"; customer: WholesaleCustomer }
| { type: "set_saving"; saving: boolean }
| { type: "set_processing_reg"; processingReg: string | null }
| { type: "set_pricing_customer"; pricingCustomer: WholesaleCustomer | null }
| { type: "toggle_selected"; id: string }
| { type: "toggle_all"; customerIds: string[] }
| { type: "clear_selected" }
| { type: "set_sending_price_sheet"; sending: boolean }
| { type: "set_price_sheet_target"; target: { customerIds: string[]; defaultSubject: string } | null }
| { type: "set_open_customer_actions"; id: string | null }
| { type: "set_deleting_customer"; id: string | null };
const initialState: State = {
showForm: false,
editing: null,
subTab: "customers",
form: EMPTY_CUSTOMER_FORM,
saving: false,
processingReg: null,
pricingCustomer: null,
selectedCustomers: new Set(),
sendingPriceSheet: false,
priceSheetTarget: null,
openCustomerActions: null,
deletingCustomer: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "set_show_form":
return { ...state, showForm: action.showForm };
case "set_sub_tab":
return { ...state, subTab: action.subTab };
case "set_form_field":
return { ...state, form: { ...state.form, [action.field]: action.value } };
case "open_new":
return { ...state, editing: null, form: EMPTY_CUSTOMER_FORM, showForm: true };
case "open_edit":
return { ...state, editing: action.customer, form: customerFormFromCustomer(action.customer), showForm: true };
case "set_saving":
return { ...state, saving: action.saving };
case "set_processing_reg":
return { ...state, processingReg: action.processingReg };
case "set_pricing_customer":
return { ...state, pricingCustomer: action.pricingCustomer };
case "toggle_selected": {
const next = new Set(state.selectedCustomers);
if (next.has(action.id)) next.delete(action.id); else next.add(action.id);
return { ...state, selectedCustomers: next };
}
case "toggle_all": {
if (state.selectedCustomers.size === action.customerIds.length) {
return { ...state, selectedCustomers: new Set() };
}
return { ...state, selectedCustomers: new Set(action.customerIds) };
}
case "clear_selected":
return { ...state, selectedCustomers: new Set() };
case "set_sending_price_sheet":
return { ...state, sendingPriceSheet: action.sending };
case "set_price_sheet_target":
return { ...state, priceSheetTarget: action.target };
case "set_open_customer_actions":
return { ...state, openCustomerActions: action.id };
case "set_deleting_customer":
return { ...state, deletingCustomer: action.id };
}
}
export default function CustomersTab({
customers,
products,
brandId,
onMsg,
registrations = EMPTY_REGISTRATIONS,
onRefresh,
}: {
customers: WholesaleCustomer[];
products: WholesaleProduct[];
brandId: string;
onMsg: (kind: "success" | "error", text: string) => void;
registrations?: Registration[];
onRefresh: () => void;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
// Close customer actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".customer-actions-cell")) {
dispatch({ type: "set_open_customer_actions", id: null });
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
async function handleApproveReject(regId: string, action: "approve" | "reject") {
dispatch({ type: "set_processing_reg", processingReg: regId });
const result = await approveWholesaleRegistration(regId, brandId, action);
dispatch({ type: "set_processing_reg", processingReg: null });
if (result.success) {
onMsg("success", action === "approve" ? "Registration approved." : "Registration rejected.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to process registration.");
}
}
async function handleDeleteCustomer(customerId: string) {
if (!confirm("Delete this customer? This cannot be undone. Customers with existing orders cannot be deleted.")) return;
dispatch({ type: "set_deleting_customer", id: customerId });
const result = await deleteWholesaleCustomer(customerId);
dispatch({ type: "set_deleting_customer", id: null });
if (result.success) {
onMsg("success", "Customer deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete customer.");
}
}
async function handleSave() {
dispatch({ type: "set_saving", saving: true });
const result = await saveWholesaleCustomer({
brandId,
userId: state.editing?.user_id ?? undefined,
companyName: state.form.companyName || undefined,
contactName: state.form.contactName || undefined,
email: state.form.email || undefined,
phone: state.form.phone || undefined,
accountStatus: state.form.accountStatus,
creditLimit: state.form.creditLimit,
depositsEnabled: state.form.depositsEnabled,
depositThreshold: state.form.depositThreshold ? Number(state.form.depositThreshold) : undefined,
depositPercentage: state.form.depositPercentage ? Number(state.form.depositPercentage) : undefined,
orderEmail: state.form.orderEmail || undefined,
invoiceEmail: state.form.invoiceEmail || undefined,
adminNotes: state.form.adminNotes || undefined,
});
dispatch({ type: "set_saving", saving: false });
if (result.success) {
onMsg("success", state.editing ? "Customer updated." : "Customer created.");
dispatch({ type: "set_show_form", showForm: false });
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save.");
}
}
function openPriceSheetModal(customerIds: string[]) {
if (customerIds.length === 0) return;
dispatch({
type: "set_price_sheet_target",
target: {
customerIds,
defaultSubject: `Wholesale Price Sheet — ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`,
},
});
}
async function handleConfirmPriceSheet(subject: string, customNote: string) {
if (!state.priceSheetTarget) return;
dispatch({ type: "set_sending_price_sheet", sending: true });
dispatch({ type: "set_price_sheet_target", target: null });
try {
const res = await fetch("/api/wholesale/price-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customerIds: state.priceSheetTarget.customerIds,
brandId,
subject,
customNote: customNote || undefined,
}),
});
const data = await res.json();
if (res.ok) {
onMsg("success", `Price sheet sent to ${data.enqueued} customer(s).`);
dispatch({ type: "clear_selected" });
} else {
onMsg("error", data.error ?? "Failed to send price sheet.");
}
} catch {
onMsg("error", "Failed to send price sheet.");
} finally {
dispatch({ type: "set_sending_price_sheet", sending: false });
}
}
const allCustomerIds = customers.map(c => c.id);
return (
<div className="space-y-4">
{/* Sub-tab nav */}
<div className="flex items-center gap-4">
<AdminButton
variant={state.subTab === "customers" ? "primary" : "secondary"}
size="sm"
onClick={() => dispatch({ type: "set_sub_tab", subTab: "customers" })}
>
Customers ({customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length})
</AdminButton>
<AdminButton
variant={state.subTab === "registrations" ? "primary" : "secondary"}
size="sm"
onClick={() => dispatch({ type: "set_sub_tab", subTab: "registrations" })}
>
Registrations ({registrations.filter(r => r.account_status === "pending_approval").length})
</AdminButton>
{state.subTab === "customers" && (
<AdminButton
variant="primary"
size="sm"
onClick={() => dispatch({ type: "open_new" })}
className="ml-auto"
>
+ Add Customer
</AdminButton>
)}
</div>
{state.subTab === "registrations" && (
<RegistrationsSection
registrations={registrations}
processingReg={state.processingReg}
onApproveReject={handleApproveReject}
/>
)}
{state.subTab === "customers" && (
<>
{state.showForm && (
<CustomerFormSection
editing={state.editing}
form={state.form}
saving={state.saving}
onFieldChange={(field, value) => dispatch({ type: "set_form_field", field, value })}
onSave={handleSave}
onCancel={() => dispatch({ type: "set_show_form", showForm: false })}
/>
)}
<CustomerListSection
customers={customers}
selectedCustomers={state.selectedCustomers}
allCustomerIds={allCustomerIds}
openCustomerActions={state.openCustomerActions}
deletingCustomer={state.deletingCustomer}
sendingPriceSheet={state.sendingPriceSheet}
onToggle={(id) => dispatch({ type: "toggle_selected", id })}
onToggleAll={() => dispatch({ type: "toggle_all", customerIds: allCustomerIds })}
onToggleActions={(id) => dispatch({ type: "set_open_customer_actions", id: state.openCustomerActions === id ? null : id })}
onCloseActions={() => dispatch({ type: "set_open_customer_actions", id: null })}
onEdit={(c) => dispatch({ type: "open_edit", customer: c })}
onPricing={(c) => dispatch({ type: "set_pricing_customer", pricingCustomer: c })}
onDelete={handleDeleteCustomer}
onSendPriceSheet={openPriceSheetModal}
/>
</>
)}
{/* Customer Pricing Overlays Panel */}
{state.pricingCustomer && (
<CustomerPricingPanel
customer={state.pricingCustomer}
products={products}
onClose={() => dispatch({ type: "set_pricing_customer", pricingCustomer: null })}
onMsg={onMsg}
/>
)}
{/* Price Sheet Modal */}
{state.priceSheetTarget && (
<PriceSheetModal
customerCount={state.priceSheetTarget.customerIds.length}
defaultSubject={state.priceSheetTarget.defaultSubject}
onConfirm={handleConfirmPriceSheet}
onClose={() => {
dispatch({ type: "set_price_sheet_target", target: null });
dispatch({ type: "set_sending_price_sheet", sending: false });
}}
/>
)}
</div>
);
}
// ── Subcomponents ─────────────────────────────────────────────────────────────
type RegistrationsSectionProps = {
registrations: Registration[];
processingReg: string | null;
onApproveReject: (regId: string, action: "approve" | "reject") => void;
};
function RegistrationsSection({ registrations, processingReg, onApproveReject }: RegistrationsSectionProps) {
const pending = registrations.filter(r => r.account_status === "pending_approval");
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-[var(--admin-text-secondary)]">Pending Registrations</h3>
{pending.length === 0 ? (
<p className="text-sm text-[var(--admin-text-muted)] py-4 text-center">No pending registrations.</p>
) : (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">Company</th>
<th className="px-5 py-3 font-semibold text-left">Contact</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Registered</th>
<th className="px-5 py-3" aria-label="Actions"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{pending.map(r => (
<tr key={r.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{r.company_name ?? "—"}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{r.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{r.email}</span></td>
<td className="px-5 py-3">
<AdminBadge variant="warning">Pending</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-muted)] text-xs">{formatDate(new Date(r.created_at))}</td>
<td className="px-5 py-3">
<div className="flex gap-2">
<AdminButton
variant="primary"
size="sm"
onClick={() => onApproveReject(r.id, "approve")}
disabled={processingReg === r.id}
isLoading={processingReg === r.id}
>
{processingReg === r.id ? "..." : "Approve"}
</AdminButton>
<AdminButton
variant="danger"
size="sm"
onClick={() => onApproveReject(r.id, "reject")}
disabled={processingReg === r.id}
>
Reject
</AdminButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import {
type WholesaleOrder,
type WholesaleDashboardStats,
} from "@/actions/wholesale";
import StatusBadge from "./StatusBadge";
export type DashboardWebhookActivity = Array<{
id: string; event_type: string; order_id: string | null;
status: string; attempts: number; created_at: string; response: string | null;
}>;
export default function DashboardTab({
stats,
recentOrders,
brandId: _brandId,
onMsg: _onMsg,
webhookActivity,
}: {
stats: WholesaleDashboardStats;
recentOrders: WholesaleOrder[];
brandId: string;
onMsg: (kind: "success" | "error", text: string) => void;
webhookActivity: DashboardWebhookActivity;
}) {
return (
<div className="space-y-6">
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{[
{ label: "Open Orders", value: stats.open_orders, variant: "default" },
{ label: "Pickup Today", value: stats.pickup_today, variant: "default" },
{ label: "Past Due", value: stats.past_due, variant: "danger" },
{ label: "Total Unpaid", value: `$${stats.total_unpaid.toFixed(2)}`, variant: "default" },
{ label: "Awaiting Deposit", value: stats.awaiting_deposit, variant: "warning" },
{ label: "Fulfilled Today", value: stats.fulfilled_today, variant: "success" },
].map((card) => (
<div key={card.label} className={`rounded-xl border shadow-sm ${card.variant === "danger" ? "bg-[var(--admin-danger-light)] border-[var(--admin-danger)]" : card.variant === "warning" ? "bg-[var(--admin-warning-light)] border-[var(--admin-warning)]" : card.variant === "success" ? "bg-[var(--admin-accent-light)] border-[var(--admin-accent)]" : "bg-white border-[var(--admin-border)]"}`}>
<p className={`text-xs ${card.variant === "danger" ? "text-[var(--admin-danger)]" : card.variant === "warning" ? "text-[var(--admin-warning)]" : card.variant === "success" ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-muted)]"}`}>{card.label}</p>
<p className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]">{card.value}</p>
</div>
))}
</div>
{/* Recent orders */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Orders</h2>
{recentOrders.length === 0 ? (
<div className="text-center py-12">
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</div>
<p className="text-base font-semibold text-slate-600 mb-1">No wholesale orders yet</p>
<p className="text-sm text-slate-400">Wholesale orders placed by your customers will appear here.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-3 font-medium">Invoice</th>
<th className="pb-3 font-medium">Customer</th>
<th className="pb-3 font-medium">Pickup Date</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Status</th>
<th className="pb-3 font-medium">Payment</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{recentOrders.map((order) => (
<tr key={order.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="py-3 font-mono text-xs text-[var(--admin-text-muted)]">{order.invoice_number ?? "—"}</td>
<td className="py-3 font-medium text-[var(--admin-text-primary)]">{order.company_name}</td>
<td className="py-3 text-[var(--admin-text-secondary)]">{order.anticipated_pickup_date ?? "—"}</td>
<td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(order.subtotal).toFixed(2)}</td>
<td className="py-3">
<StatusBadge status={order.status} />
</td>
<td className="py-3">
<span className={`text-xs font-medium ${
order.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"
}`}>
{order.payment_status === "paid" ? "Paid" : order.balance_due > 0 ? `$${Number(order.balance_due).toFixed(2)} due` : "Partial"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Recent webhook activity */}
{webhookActivity.length > 0 && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Webhook Activity</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--admin-text-muted)] border-b">
<th className="pb-3 font-medium">Event</th>
<th className="pb-3 font-medium">Order</th>
<th className="pb-3 font-medium">Status</th>
<th className="pb-3 font-medium">Attempts</th>
<th className="pb-3 font-medium">Sent At</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{webhookActivity.map((entry) => (
<tr key={entry.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="py-3">
<span className="font-mono text-xs bg-[var(--admin-accent)] text-white px-2 py-0.5 rounded">{entry.event_type}</span>
</td>
<td className="py-3 font-mono text-xs text-[var(--admin-text-muted)]">{entry.order_id ? entry.order_id.slice(0, 8) : "—"}</td>
<td className="py-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
entry.status === "sent" ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" :
entry.status === "failed" ? "bg-[var(--admin-danger-light)] text-[var(--admin-danger)]" :
entry.status === "retrying" ? "bg-[var(--admin-warning-light)] text-[var(--admin-warning)]" :
"bg-[var(--admin-border)] text-[var(--admin-text-secondary)]"
}`}>
{entry.status}
</span>
</td>
<td className="py-3 text-[var(--admin-text-muted)]">{entry.attempts}</td>
<td className="py-3 text-[var(--admin-text-muted)]">{new Date(entry.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
"use client";
import { AdminButton } from "@/components/admin/design-system";
export default function OrdersBulkActionsBar({
selectedCount,
bulkLoading,
onBulkFulfill,
onOpenBulkDeposit,
onClearSelection,
}: {
selectedCount: number;
bulkLoading: boolean;
onBulkFulfill: () => void;
onOpenBulkDeposit: () => void;
onClearSelection: () => void;
}) {
return (
<div className="flex items-center gap-3 rounded-2xl bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-4 py-3">
<span className="text-sm font-medium text-[var(--admin-accent-text)]">{selectedCount} selected</span>
<AdminButton variant="primary" size="sm" onClick={onBulkFulfill} disabled={bulkLoading} isLoading={bulkLoading}>
{bulkLoading ? "..." : "Bulk Fulfill"}
</AdminButton>
<AdminButton variant="secondary" size="sm" onClick={onOpenBulkDeposit}>
Bulk Record Deposit
</AdminButton>
<button type="button" onClick={onClearSelection} className="ml-auto text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Clear selection</button>
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { AdminButton } from "@/components/admin/design-system";
export default function OrdersBulkDepositModal({
selectedCount,
bulkDepAmount,
bulkDepMethod,
bulkLoading,
onAmountChange,
onMethodChange,
onConfirm,
onCancel,
}: {
selectedCount: number;
bulkDepAmount: string;
bulkDepMethod: string;
bulkLoading: boolean;
onAmountChange: (v: string) => void;
onMethodChange: (v: string) => void;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Bulk Record Deposit ({selectedCount} orders)</h3>
<div className="space-y-3">
<div>
<label htmlFor="fld-10-amount-per-order" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount per order ($)</label>
<input id="fld-10-amount-per-order" aria-label="0.00" type="number" value={bulkDepAmount} onChange={e => onAmountChange(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" placeholder="0.00" />
</div>
<div>
<label htmlFor="fld-11-method-2" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
<select id="fld-11-method-2" aria-label="Select" value={bulkDepMethod} onChange={e => onMethodChange(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="wire">Wire</option>
<option value="card">Card</option>
</select>
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={onConfirm} variant="primary" disabled={!bulkDepAmount || bulkLoading} isLoading={bulkLoading}>
{bulkLoading ? "Recording..." : "Record on All"}
</AdminButton>
<AdminButton onClick={onCancel} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { AdminButton } from "@/components/admin/design-system";
export default function OrdersDepositModal({
depAmount,
depMethod,
onAmountChange,
onMethodChange,
onConfirm,
onCancel,
}: {
depAmount: string;
depMethod: string;
onAmountChange: (v: string) => void;
onMethodChange: (v: string) => void;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Record Deposit</h3>
<div className="space-y-3">
<div>
<label htmlFor="fld-8-amount" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount ($)</label>
<input id="fld-8-amount" aria-label="Number" type="number" value={depAmount} onChange={e => onAmountChange(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" />
</div>
<div>
<label htmlFor="fld-9-method" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
<select id="fld-9-method" aria-label="Select" value={depMethod} onChange={e => onMethodChange(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="wire">Wire</option>
<option value="card">Card</option>
</select>
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={onConfirm} variant="primary">
Record Deposit
</AdminButton>
<AdminButton onClick={onCancel} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import { AdminSearchInput } from "@/components/admin/design-system";
export type OrdersFilters = {
statusFilter: string;
dateFrom: string;
dateTo: string;
searchQuery: string;
};
export default function OrdersFilterBar({
filters,
filteredCount,
totalCount,
onStatusChange,
onDateFromChange,
onDateToChange,
onSearchChange,
onSearchClear,
}: {
filters: OrdersFilters;
filteredCount: number;
totalCount: number;
onStatusChange: (v: string) => void;
onDateFromChange: (v: string) => void;
onDateToChange: (v: string) => void;
onSearchChange: (v: string) => void;
onSearchClear: () => void;
}) {
return (
<div className="flex flex-wrap items-center gap-3 rounded-2xl bg-white border border-[var(--admin-border)] p-4 shadow-sm">
<div>
<label htmlFor="fld-5-status" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
<select id="fld-5-status" aria-label="Select" value={filters.statusFilter} onChange={e => onStatusChange(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="awaiting_deposit">Awaiting Deposit</option>
<option value="confirmed">Confirmed</option>
<option value="fulfilled">Fulfilled</option>
</select>
</div>
<div>
<label htmlFor="fld-6-pickup-from" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup From</label>
<input id="fld-6-pickup-from" aria-label="Date" type="date" value={filters.dateFrom} onChange={e => onDateFromChange(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="fld-7-pickup-to" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup To</label>
<input id="fld-7-pickup-to" aria-label="Date" type="date" value={filters.dateTo} onChange={e => onDateToChange(e.target.value)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="flex-1 min-w-[180px]">
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1" htmlFor="fld-search">Search</label>
<AdminSearchInput
value={filters.searchQuery}
onChange={e => onSearchChange(e.target.value)}
onClear={onSearchClear}
placeholder="Invoice, customer, email..."
/>
</div>
{filteredCount !== totalCount && (
<div className="self-end pb-1">
<span className="text-xs text-[var(--admin-text-muted)]">{filteredCount} of {totalCount}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { AdminButton } from "@/components/admin/design-system";
export default function OrdersManifestSection({
manifestLoading,
onGenerateManifest,
}: {
manifestLoading: boolean;
onGenerateManifest: () => void;
}) {
return (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-4 flex items-center gap-4 shadow-sm">
<div>
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Load Manifest</p>
<p className="text-xs text-[var(--admin-text-muted)]">Generate a printable manifest for pending/active orders.</p>
</div>
<AdminButton
variant="secondary"
size="sm"
onClick={onGenerateManifest}
disabled={manifestLoading}
isLoading={manifestLoading}
>
{manifestLoading ? "Generating..." : "Generate Manifest"}
</AdminButton>
</div>
);
}
+364
View File
@@ -0,0 +1,364 @@
"use client";
import { useReducer, useEffect, useRef } from "react";
import {
type WholesaleOrder,
type WholesaleCustomer,
bulkFulfillWholesaleOrders,
bulkRecordWholesaleDeposit,
recordWholesaleDeposit,
} from "@/actions/wholesale";
import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal";
import OrdersFilterBar from "./OrdersFilterBar";
import OrdersBulkActionsBar from "./OrdersBulkActionsBar";
import OrdersManifestSection from "./OrdersManifestSection";
import OrdersTable from "./OrdersTable";
import OrdersDepositModal from "./OrdersDepositModal";
import OrdersBulkDepositModal from "./OrdersBulkDepositModal";
import { fulfillWithNotifications, openManifestWindow } from "./ordersActions";
type State = {
showDepForm: string | null;
depAmount: string;
depMethod: string;
fulfilling: string | null;
manifestLoading: boolean;
selected: Set<string>;
statusFilter: string;
dateFrom: string;
dateTo: string;
searchQuery: string;
bulkDepAmount: string;
bulkDepMethod: string;
showBulkDep: boolean;
bulkLoading: boolean;
openActions: string | null;
showViewOrder: WholesaleOrder | null;
};
type Action =
| { type: "set_show_dep_form"; id: string | null }
| { type: "set_dep_amount"; amount: string }
| { type: "set_dep_method"; method: string }
| { type: "set_fulfilling"; id: string | null }
| { type: "set_manifest_loading"; loading: boolean }
| { type: "toggle_selected"; id: string }
| { type: "toggle_all"; ids: string[] }
| { type: "clear_selected" }
| { type: "set_status_filter"; value: string }
| { type: "set_date_from"; value: string }
| { type: "set_date_to"; value: string }
| { type: "set_search_query"; value: string }
| { type: "set_bulk_dep_amount"; amount: string }
| { type: "set_bulk_dep_method"; method: string }
| { type: "set_show_bulk_dep"; show: boolean }
| { type: "set_bulk_loading"; loading: boolean }
| { type: "set_open_actions"; id: string | null }
| { type: "set_show_view_order"; order: WholesaleOrder | null };
const initialState: State = {
showDepForm: null,
depAmount: "",
depMethod: "cash",
fulfilling: null,
manifestLoading: false,
selected: new Set(),
statusFilter: "all",
dateFrom: "",
dateTo: "",
searchQuery: "",
bulkDepAmount: "",
bulkDepMethod: "cash",
showBulkDep: false,
bulkLoading: false,
openActions: null,
showViewOrder: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "set_show_dep_form":
return { ...state, showDepForm: action.id };
case "set_dep_amount":
return { ...state, depAmount: action.amount };
case "set_dep_method":
return { ...state, depMethod: action.method };
case "set_fulfilling":
return { ...state, fulfilling: action.id };
case "set_manifest_loading":
return { ...state, manifestLoading: action.loading };
case "toggle_selected": {
const next = new Set(state.selected);
if (next.has(action.id)) next.delete(action.id); else next.add(action.id);
return { ...state, selected: next };
}
case "toggle_all": {
if (state.selected.size === action.ids.length) {
return { ...state, selected: new Set() };
}
return { ...state, selected: new Set(action.ids) };
}
case "clear_selected":
return { ...state, selected: new Set() };
case "set_status_filter":
return { ...state, statusFilter: action.value };
case "set_date_from":
return { ...state, dateFrom: action.value };
case "set_date_to":
return { ...state, dateTo: action.value };
case "set_search_query":
return { ...state, searchQuery: action.value };
case "set_bulk_dep_amount":
return { ...state, bulkDepAmount: action.amount };
case "set_bulk_dep_method":
return { ...state, bulkDepMethod: action.method };
case "set_show_bulk_dep":
return { ...state, showBulkDep: action.show };
case "set_bulk_loading":
return { ...state, bulkLoading: action.loading };
case "set_open_actions":
return { ...state, openActions: action.id };
case "set_show_view_order":
return { ...state, showViewOrder: action.order };
}
}
export default function OrdersTab({ orders, customers, brandId, onMsg, onRefresh }: {
orders: WholesaleOrder[];
customers: WholesaleCustomer[];
brandId: string;
onMsg: (kind: "success" | "error", text: string) => void;
onRefresh: () => void;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const deletingRef = useRef<string | null>(null);
const filtered = orders.filter(o => {
if (state.statusFilter !== "all" && o.status !== state.statusFilter) return false;
if (state.dateFrom && (!o.anticipated_pickup_date || o.anticipated_pickup_date < state.dateFrom)) return false;
if (state.dateTo && (!o.anticipated_pickup_date || o.anticipated_pickup_date > state.dateTo)) return false;
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
if (!(o.company_name?.toLowerCase().includes(q) || o.invoice_number?.toLowerCase().includes(q) || o.customer_email?.toLowerCase().includes(q))) return false;
}
return true;
});
// Close actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".actions-cell")) {
dispatch({ type: "set_open_actions", id: null });
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
async function handleBulkFulfill() {
if (state.selected.size === 0) return;
dispatch({ type: "set_bulk_loading", loading: true });
const result = await bulkFulfillWholesaleOrders(Array.from(state.selected));
dispatch({ type: "set_bulk_loading", loading: false });
if (result.success) {
onMsg("success", `${result.count} order(s) marked as fulfilled.`);
dispatch({ type: "clear_selected" });
onRefresh();
} else {
onMsg("error", result.error ?? "Bulk fulfill failed.");
}
}
async function handleBulkDeposit() {
if (state.selected.size === 0 || !state.bulkDepAmount) return;
dispatch({ type: "set_bulk_loading", loading: true });
const result = await bulkRecordWholesaleDeposit(Array.from(state.selected), Number(state.bulkDepAmount), state.bulkDepMethod);
dispatch({ type: "set_bulk_loading", loading: false });
if (result.success) {
onMsg("success", `Deposit recorded on ${result.count} order(s).`);
dispatch({ type: "clear_selected" });
dispatch({ type: "set_show_bulk_dep", show: false });
dispatch({ type: "set_bulk_dep_amount", amount: "" });
onRefresh();
} else {
onMsg("error", result.error ?? "Bulk deposit failed.");
}
}
async function handleFulfill(orderId: string) {
dispatch({ type: "set_fulfilling", id: orderId });
const result = await fulfillWithNotifications(orderId, orders, customers, brandId);
dispatch({ type: "set_fulfilling", id: null });
if (result.success) {
onMsg("success", "Order marked as fulfilled.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to fulfill order.");
}
}
async function handleRecordDeposit(orderId: string) {
const result = await recordWholesaleDeposit(orderId, Number(state.depAmount), state.depMethod);
dispatch({ type: "set_show_dep_form", id: null });
dispatch({ type: "set_dep_amount", amount: "" });
if (result.success) {
onMsg("success", "Deposit recorded.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to record deposit.");
}
}
async function handleCancelOrder(orderId: string) {
if (!confirm("Cancel this order? This cannot be undone.")) return;
const { updateWholesaleOrderStatus } = await import("@/actions/wholesale");
const result = await updateWholesaleOrderStatus(orderId, "cancelled");
if (result.success) {
onMsg("success", "Order cancelled.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to cancel order.");
}
}
async function handleDeleteOrder(orderId: string) {
if (!confirm("Delete this order? Fulfilled and paid orders cannot be deleted. This cannot be undone.")) return;
deletingRef.current = orderId;
const { deleteWholesaleOrder } = await import("@/actions/wholesale");
const result = await deleteWholesaleOrder(orderId);
deletingRef.current = null;
if (result.success) {
onMsg("success", "Order deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete order.");
}
}
function handleGenerateManifest() {
dispatch({ type: "set_manifest_loading", loading: true });
const pending = filtered.filter(o => o.fulfillment_status !== "fulfilled");
if (pending.length === 0) {
dispatch({ type: "set_manifest_loading", loading: false });
return;
}
openManifestWindow(pending, brandId);
dispatch({ type: "set_manifest_loading", loading: false });
}
function handleGenerateManifestForOrder(o: WholesaleOrder) {
openManifestWindow([o], brandId);
}
function handleSendPriceSheetForOrder(o: WholesaleOrder) {
fetch("/api/wholesale/price-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerIds: [o.customer_id], brandId }),
})
.then(r => r.json())
.then(() => onMsg("success", `Price sheet sent to customer.`))
.catch(() => onMsg("error", "Failed to send price sheet."));
}
const filteredIds = filtered.map(o => o.id);
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Orders ({orders.length})</h2>
{/* Filter bar */}
<OrdersFilterBar
filters={{
statusFilter: state.statusFilter,
dateFrom: state.dateFrom,
dateTo: state.dateTo,
searchQuery: state.searchQuery,
}}
filteredCount={filtered.length}
totalCount={orders.length}
onStatusChange={(v) => dispatch({ type: "set_status_filter", value: v })}
onDateFromChange={(v) => dispatch({ type: "set_date_from", value: v })}
onDateToChange={(v) => dispatch({ type: "set_date_to", value: v })}
onSearchChange={(v) => dispatch({ type: "set_search_query", value: v })}
onSearchClear={() => dispatch({ type: "set_search_query", value: "" })}
/>
{/* Bulk actions */}
{state.selected.size > 0 && (
<OrdersBulkActionsBar
selectedCount={state.selected.size}
bulkLoading={state.bulkLoading}
onBulkFulfill={handleBulkFulfill}
onOpenBulkDeposit={() => dispatch({ type: "set_show_bulk_dep", show: true })}
onClearSelection={() => dispatch({ type: "clear_selected" })}
/>
)}
{/* Manifest section */}
<OrdersManifestSection
manifestLoading={state.manifestLoading}
onGenerateManifest={handleGenerateManifest}
/>
<OrdersTable
orders={filtered}
selected={state.selected}
fulfilling={state.fulfilling}
openActions={state.openActions}
onToggleSelect={(id) => dispatch({ type: "toggle_selected", id })}
onToggleAll={() => dispatch({ type: "toggle_all", ids: filteredIds })}
onFulfill={handleFulfill}
onRecordDeposit={(id) => dispatch({ type: "set_show_dep_form", id })}
onToggleActions={(id) => dispatch({ type: "set_open_actions", id: state.openActions === id ? null : id })}
onCloseActions={() => dispatch({ type: "set_open_actions", id: null })}
onViewOrder={(o) => dispatch({ type: "set_show_view_order", order: o })}
onGenerateManifestForOrder={handleGenerateManifestForOrder}
onSendPriceSheetForOrder={handleSendPriceSheetForOrder}
onCancelOrder={handleCancelOrder}
onDeleteOrder={handleDeleteOrder}
/>
{/* Deposit modal */}
{state.showDepForm && (
<OrdersDepositModal
depAmount={state.depAmount}
depMethod={state.depMethod}
onAmountChange={(v) => dispatch({ type: "set_dep_amount", amount: v })}
onMethodChange={(v) => dispatch({ type: "set_dep_method", method: v })}
onConfirm={() => handleRecordDeposit(state.showDepForm!)}
onCancel={() => dispatch({ type: "set_show_dep_form", id: null })}
/>
)}
{/* Bulk Deposit modal */}
{state.showBulkDep && (
<OrdersBulkDepositModal
selectedCount={state.selected.size}
bulkDepAmount={state.bulkDepAmount}
bulkDepMethod={state.bulkDepMethod}
bulkLoading={state.bulkLoading}
onAmountChange={(v) => dispatch({ type: "set_bulk_dep_amount", amount: v })}
onMethodChange={(v) => dispatch({ type: "set_bulk_dep_method", method: v })}
onConfirm={handleBulkDeposit}
onCancel={() => dispatch({ type: "set_show_bulk_dep", show: false })}
/>
)}
{/* Order Details modal */}
{state.showViewOrder && (
<OrderDetailsModal
order={state.showViewOrder}
onClose={() => dispatch({ type: "set_show_view_order", order: null })}
onFulfill={handleFulfill}
onRecordDeposit={(id) => {
dispatch({ type: "set_show_view_order", order: null });
dispatch({ type: "set_show_dep_form", id });
}}
fulfilling={state.fulfilling}
/>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import {
type WholesaleOrder,
} from "@/actions/wholesale";
import { AdminButton } from "@/components/admin/design-system";
import StatusBadge from "./StatusBadge";
export default function OrdersTable({
orders,
selected,
fulfilling,
openActions,
onToggleSelect,
onToggleAll,
onFulfill,
onRecordDeposit,
onToggleActions,
onCloseActions,
onViewOrder,
onGenerateManifestForOrder,
onSendPriceSheetForOrder,
onCancelOrder,
onDeleteOrder,
}: {
orders: WholesaleOrder[];
selected: Set<string>;
fulfilling: string | null;
openActions: string | null;
onToggleSelect: (id: string) => void;
onToggleAll: () => void;
onFulfill: (id: string) => void;
onRecordDeposit: (id: string) => void;
onToggleActions: (id: string) => void;
onCloseActions: () => void;
onViewOrder: (o: WholesaleOrder) => void;
onGenerateManifestForOrder: (o: WholesaleOrder) => void;
onSendPriceSheetForOrder: (o: WholesaleOrder) => void;
onCancelOrder: (id: string) => void;
onDeleteOrder: (id: string) => void;
}) {
return (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left w-10">
<input id="fld-search" aria-label="Checkbox" type="checkbox" checked={selected.size === orders.length && orders.length > 0} onChange={onToggleAll} className="rounded" />
</th>
<th className="px-5 py-3 font-semibold text-left">Invoice</th>
<th className="px-5 py-3 font-semibold text-left">Customer</th>
<th className="px-5 py-3 font-semibold text-left">Pickup Date</th>
<th className="px-5 py-3 font-semibold text-right">Subtotal</th>
<th className="px-5 py-3 font-semibold text-right">Deposit</th>
<th className="px-5 py-3 font-semibold text-right">Balance</th>
<th className="px-5 py-3 font-semibold text-left">Status</th>
<th className="px-5 py-3 font-semibold text-left">Payment</th>
<th className="px-5 py-3 font-semibold text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{orders.length === 0 ? (
<tr><td colSpan={10} className="py-8 text-center text-[var(--admin-text-muted)]">No orders match the current filters.</td></tr>
) : orders.map(o => (
<tr key={o.id} className={`hover:bg-[var(--admin-bg-subtle)] ${selected.has(o.id) ? "bg-[var(--admin-accent-light)]" : ""}`}>
<td className="px-5 py-3">
<input aria-label="Checkbox" type="checkbox" checked={selected.has(o.id)} onChange={() => onToggleSelect(o.id)} className="rounded" />
</td>
<td className="px-5 py-3 font-mono text-xs text-[var(--admin-text-muted)]">{o.invoice_number ?? "—"}</td>
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{o.company_name}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{o.anticipated_pickup_date ?? "—"}</td>
<td className="px-5 py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(o.subtotal).toFixed(2)}</td>
<td className="px-5 py-3 text-right text-[var(--admin-text-secondary)]">${Number(o.deposit_paid).toFixed(2)} / ${Number(o.deposit_required).toFixed(2)}</td>
<td className="px-5 py-3 text-right">
<span className={Number(o.balance_due) > 0 ? "text-[var(--admin-warning)] font-medium" : "text-[var(--admin-accent)]"}>
${Number(o.balance_due).toFixed(2)}
</span>
</td>
<td className="px-5 py-3"><StatusBadge status={o.status} /></td>
<td className="px-5 py-3">
<span className={`text-xs font-medium ${o.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"}`}>
{o.payment_status}
</span>
</td>
<td className="px-5 py-4 actions-cell relative">
<div className="flex items-center gap-2">
{/* Primary: Mark Fulfilled — only when order is not yet fulfilled */}
{o.fulfillment_status !== "fulfilled" && (
<AdminButton
variant="primary"
size="sm"
onClick={() => !fulfilling && onFulfill(o.id)}
disabled={fulfilling === o.id}
isLoading={fulfilling === o.id}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
{fulfilling === o.id ? "..." : "Fulfill"}
</AdminButton>
)}
{/* Primary: Record Deposit — when there's a balance due */}
{Number(o.balance_due) > 0 && o.fulfillment_status !== "fulfilled" && (
<AdminButton
variant="secondary"
size="sm"
onClick={() => onRecordDeposit(o.id)}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-9-9h18" /></svg>
Deposit
</AdminButton>
)}
{/* Invoice download — always visible as icon button */}
<a aria-label="Download Invoice"
href={`/api/wholesale/invoice/${o.id}/pdf`}
target="_blank"
rel="noopener noreferrer" title="Download Invoice"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 110 12H15M6 2h9l4 4v14a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/></svg>
</a>
{/* ⋮ Actions dropdown — opens upward to avoid table cutoff */}
<div className="relative">
<button type="button"
onClick={(e) => { e.stopPropagation(); onToggleActions(o.id); }}
title="More actions"
aria-label="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
{openActions === o.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-56 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { onCloseActions(); onViewOrder(o); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
>
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
View / Edit Order
</button>
<button type="button"
onClick={() => { onCloseActions(); onGenerateManifestForOrder(o); }}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 01-18 0 9 9 0 0118 0z"/></svg>
Generate Manifest
</button>
<button type="button"
onClick={() => { onCloseActions(); onSendPriceSheetForOrder(o); }}
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
Send Price Sheet
</button>
<div className="my-1 border-t border-[var(--admin-border)]" />
{o.status !== "cancelled" && o.fulfillment_status !== "fulfilled" && (
<button type="button"
onClick={() => { onCloseActions(); onCancelOrder(o.id); }}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
Cancel Order
</button>
)}
{o.fulfillment_status !== "fulfilled" && (
<button type="button"
onClick={() => { onCloseActions(); onDeleteOrder(o.id); }}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Delete Order
</button>
)}
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { AdminButton } from "@/components/admin/design-system";
export default function PriceSheetModal({
customerCount,
defaultSubject,
onConfirm,
onClose,
}: {
customerCount: number;
defaultSubject: string;
onConfirm: (subject: string, customNote: string) => void;
onClose: () => void;
}) {
const [subject, setSubject] = useState(defaultSubject);
const [note, setNote] = useState("");
const [sending, setSending] = useState(false);
const dialogRef = useRef<HTMLDialogElement | null>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!subject.trim()) return;
setSending(true);
onConfirm(subject.trim(), note.trim());
}
return (
<dialog
ref={dialogRef}
aria-label="Send price sheet"
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{customerCount === 1 ? "1 customer" : `${customerCount} customers`}
</p>
</div>
<button type="button" onClick={onClose} aria-label="Close send price sheet" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-5 space-y-4">
<div>
<label htmlFor="ws-price-subject" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">Subject</label>
<input aria-label="Ws Price Subject"
id="ws-price-subject"
value={subject}
onChange={e => setSubject(e.target.value)}
required
aria-required="true"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div>
<label htmlFor="ws-price-note" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">
Add a Note <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
</label>
<textarea aria-label=". 'Check Out Our New Seasonal Items On Page 2. Place Your Order By Friday For Weekend Pickup.'"
id="ws-price-note"
value={note}
onChange={e => setNote(e.target.value)}
rows={4}
placeholder="e.g. 'Check out our new seasonal items on page 2. Place your order by Friday for weekend pickup.'"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] resize-none"
/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This note will appear at the top of the email, above the product table.
</p>
</div>
</div>
<div className="px-6 py-4 border-t border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)] rounded-b-2xl">
<div className="text-sm text-[var(--admin-text-muted)]">
{customerCount === 1 ? "1 customer" : `${customerCount} customers`} will receive this email.
</div>
<div className="flex gap-3">
<AdminButton type="button" variant="secondary" onClick={onClose}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={sending || !subject.trim()} isLoading={sending}>
{sending ? "Sending..." : `Send to ${customerCount === 1 ? "1 Customer" : `${customerCount} Customers`}`}
</AdminButton>
</div>
</div>
</form>
</div>
</dialog>
);
}
+316
View File
@@ -0,0 +1,316 @@
"use client";
import { useReducer, useEffect } from "react";
import {
type WholesaleProduct,
deleteWholesaleProduct,
saveWholesaleProduct,
} from "@/actions/wholesale";
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
type ProductForm = {
name: string;
description: string;
unitType: string;
availability: string;
qtyAvailable: number;
priceTiers: string;
hpSku: string;
hpItemId: string;
handlingInstructions: string;
storageWarning: string;
productLabel: string;
};
const EMPTY_PRODUCT_FORM: ProductForm = {
name: "",
description: "",
unitType: "each",
availability: "available",
qtyAvailable: 0,
priceTiers: "",
hpSku: "",
hpItemId: "",
handlingInstructions: "",
storageWarning: "",
productLabel: "",
};
function productFormFromProduct(p: WholesaleProduct): ProductForm {
return {
name: p.name,
description: p.description ?? "",
unitType: p.unit_type,
availability: p.availability,
qtyAvailable: Number(p.qty_available),
priceTiers: JSON.stringify(p.price_tiers),
hpSku: p.hp_sku ?? "",
hpItemId: p.hp_item_id ?? "",
handlingInstructions: p.handling_instructions ?? "",
storageWarning: p.storage_warning ?? "",
productLabel: p.product_label ?? "",
};
}
type State = {
showForm: boolean;
editing: WholesaleProduct | null;
form: ProductForm;
saving: boolean;
openProductActions: string | null;
deletingProduct: string | null;
};
type Action =
| { type: "open_new" }
| { type: "open_edit"; product: WholesaleProduct }
| { type: "set_show_form"; showForm: boolean }
| { type: "set_form_field"; field: keyof ProductForm; value: ProductForm[keyof ProductForm] }
| { type: "set_saving"; saving: boolean }
| { type: "set_open_product_actions"; id: string | null }
| { type: "set_deleting_product"; id: string | null };
const initialState: State = {
showForm: false,
editing: null,
form: EMPTY_PRODUCT_FORM,
saving: false,
openProductActions: null,
deletingProduct: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "open_new":
return { ...state, editing: null, form: EMPTY_PRODUCT_FORM, showForm: true };
case "open_edit":
return { ...state, editing: action.product, form: productFormFromProduct(action.product), showForm: true };
case "set_show_form":
return { ...state, showForm: action.showForm };
case "set_form_field":
return { ...state, form: { ...state.form, [action.field]: action.value } };
case "set_saving":
return { ...state, saving: action.saving };
case "set_open_product_actions":
return { ...state, openProductActions: action.id };
case "set_deleting_product":
return { ...state, deletingProduct: action.id };
}
}
export default function ProductsTab({ products, brandId, onMsg, onRefresh }: {
products: WholesaleProduct[];
brandId: string;
onMsg: (kind: "success" | "error", text: string) => void;
onRefresh: () => void;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
// Close product actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".product-actions-cell")) {
dispatch({ type: "set_open_product_actions", id: null });
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
async function handleDeleteProduct(productId: string) {
if (!confirm("Delete this product? Products attached to order items cannot be deleted. Set availability to unavailable instead.")) return;
dispatch({ type: "set_deleting_product", id: productId });
const result = await deleteWholesaleProduct(productId);
dispatch({ type: "set_deleting_product", id: null });
if (result.success) {
onMsg("success", "Product deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete product.");
}
}
async function handleSave() {
dispatch({ type: "set_saving", saving: true });
let tiers: Array<{ min_qty: number; max_qty: number; price: number }> = [];
try { tiers = JSON.parse(state.form.priceTiers || "[]"); } catch {}
const result = await saveWholesaleProduct({
brandId,
id: state.editing?.id,
name: state.form.name,
description: state.form.description || undefined,
unitType: state.form.unitType,
availability: state.form.availability,
qtyAvailable: state.form.qtyAvailable,
priceTiers: tiers,
hpSku: state.form.hpSku || undefined,
hpItemId: state.form.hpItemId || undefined,
handlingInstructions: state.form.handlingInstructions || undefined,
storageWarning: state.form.storageWarning || undefined,
productLabel: state.form.productLabel || undefined,
});
dispatch({ type: "set_saving", saving: false });
if (result.success) {
onMsg("success", state.editing ? "Product updated." : "Product created.");
dispatch({ type: "set_show_form", showForm: false });
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save.");
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Products ({products.length})</h2>
<AdminButton variant="primary" size="sm" onClick={() => dispatch({ type: "open_new" })}>
+ Add Product
</AdminButton>
</div>
{state.showForm && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{state.editing ? "Edit Product" : "New Product"}</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label htmlFor="ws-prod-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label>
<input aria-label="Ws Prod Name" id="ws-prod-name" value={state.form.name} onChange={e => dispatch({ type: "set_form_field", field: "name", value: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="ws-prod-desc" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label>
<textarea aria-label="Ws Prod Desc" id="ws-prod-desc" value={state.form.description} onChange={e => dispatch({ type: "set_form_field", field: "description", value: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
</div>
<div>
<label htmlFor="ws-prod-unit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label>
<select aria-label="Ws Prod Unit" id="ws-prod-unit" value={state.form.unitType} onChange={e => dispatch({ type: "set_form_field", field: "unitType", value: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
{["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
<div>
<label htmlFor="fld-1-availability" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Availability</label>
<select id="fld-1-availability" aria-label="Select" value={state.form.availability} onChange={e => dispatch({ type: "set_form_field", field: "availability", value: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="available">Available</option>
<option value="unavailable">Unavailable</option>
<option value="coming_soon">Coming Soon</option>
</select>
</div>
<div>
<label htmlFor="fld-2-qty-available" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Qty Available</label>
<input id="fld-2-qty-available" aria-label="Number" type="number" value={state.form.qtyAvailable} onChange={e => dispatch({ type: "set_form_field", field: "qtyAvailable", value: Number(e.target.value) })}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="fld-3-hp-sku" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">HP SKU</label>
<input id="fld-3-hp-sku" aria-label="Input" value={state.form.hpSku} onChange={e => dispatch({ type: "set_form_field", field: "hpSku", value: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-4-price-tiers-json-array-of-min-qty1max-qty24price20" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Price Tiers (JSON array of {"{\"min_qty\":1,\"max_qty\":24,\"price\":20}"})</label>
<input id="fld-4-price-tiers-json-array-of-min-qty1max-qty24price20" aria-label="[{&quot;min Qty&quot;:1,&quot;max Qty&quot;:24,&quot;price&quot;:20},{&quot;min Qty&quot;:25,&quot;max Qty&quot;:0,&quot;price&quot;:18}]" value={state.form.priceTiers} onChange={e => dispatch({ type: "set_form_field", field: "priceTiers", value: e.target.value })}
placeholder='[{"min_qty":1,"max_qty":24,"price":20},{"min_qty":25,"max_qty":0,"price":18}]'
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm font-mono outline-none focus:border-[var(--admin-accent)]" />
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleSave} disabled={state.saving} variant="primary" isLoading={state.saving}>
{state.saving ? "Saving..." : "Save Product"}
</AdminButton>
<AdminButton onClick={() => dispatch({ type: "set_show_form", showForm: false })} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
)}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">Product</th>
<th className="px-5 py-3 font-semibold text-left">Unit</th>
<th className="px-5 py-3 font-semibold text-left">Availability</th>
<th className="px-5 py-3 font-semibold text-left">In Stock</th>
<th className="px-5 py-3 font-semibold text-left">HP SKU</th>
<th className="px-5 py-3" aria-label="Actions"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{products.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No wholesale products yet</p>
<p className="text-xs text-slate-400">Add products to your wholesale catalog to get started.</p>
</div>
</td>
</tr>
) : products.map(p => (
<tr key={p.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.unit_type}</td>
<td className="px-5 py-3">
<AdminBadge variant={p.availability === "available" ? "success" : p.availability === "coming_soon" ? "warning" : "default"}>
{p.availability.replace("_", " ")}
</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.qty_available}</td>
<td className="px-5 py-4 font-mono text-xs text-[var(--admin-text-muted)]">{p.hp_sku ?? "—"}</td>
<td className="px-5 py-4 product-actions-cell relative">
<div className="flex items-center gap-1.5">
<div className="relative">
<button type="button"
onClick={(e) => {
e.stopPropagation();
dispatch({ type: "set_open_product_actions", id: state.openProductActions === p.id ? null : p.id });
}}
title="More actions"
aria-label="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
{state.openProductActions === p.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-48 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button type="button"
onClick={() => { dispatch({ type: "set_open_product_actions", id: null }); dispatch({ type: "open_edit", product: p }); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
>
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit Product
</button>
<div className="my-1 border-t border-[var(--admin-border)]" />
<button type="button"
onClick={() => { dispatch({ type: "set_open_product_actions", id: null }); handleDeleteProduct(p.id); }}
disabled={state.deletingProduct === p.id}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{state.deletingProduct === p.id ? "Deleting..." : "Delete Product"}
</button>
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+314
View File
@@ -0,0 +1,314 @@
"use client";
import { useState } from "react";
import {
type WholesaleSettings,
type NotificationRecipient,
saveWholesaleSettings,
} from "@/actions/wholesale";
import { AdminButton } from "@/components/admin/design-system";
import AddRecipientForm from "./AddRecipientForm";
import WebhookSettingsSection from "./WebhookSettingsSection";
type SettingsForm = {
requireApproval: boolean;
minOrderAmount: string;
onlinePaymentEnabled: boolean;
wholesaleEnabled: boolean;
squareSyncEnabled: boolean;
pickupLocation: string;
fobLocation: string;
fromEmail: string;
invoiceBusinessName: string;
invoiceBusinessAddress: string;
invoiceBusinessPhone: string;
invoiceBusinessEmail: string;
invoiceBusinessWebsite: string;
notificationRecipients: NotificationRecipient[];
};
function settingsFormFromSettings(settings: WholesaleSettings | null): SettingsForm {
return {
requireApproval: settings?.require_approval ?? true,
minOrderAmount: settings?.min_order_amount != null ? String(settings.min_order_amount) : "",
onlinePaymentEnabled: settings?.online_payment_enabled ?? false,
wholesaleEnabled: settings?.wholesale_enabled ?? true,
squareSyncEnabled: settings?.square_sync_enabled ?? false,
pickupLocation: settings?.pickup_location ?? "",
fobLocation: settings?.fob_location ?? "",
fromEmail: settings?.from_email ?? "",
invoiceBusinessName: settings?.invoice_business_name ?? "",
invoiceBusinessAddress: settings?.invoice_business_address ?? "",
invoiceBusinessPhone: settings?.invoice_business_phone ?? "",
invoiceBusinessEmail: settings?.invoice_business_email ?? "",
invoiceBusinessWebsite: settings?.invoice_business_website ?? "",
notificationRecipients: settings?.notification_recipients ?? [],
};
}
export default function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }: {
settings: WholesaleSettings | null;
brandId: string;
onMsg: (kind: "success" | "error", text: string) => void;
onRefresh: () => void;
canManageSettings: boolean;
}) {
// Hooks must be called unconditionally - always declare them first
const [form, setForm] = useState<SettingsForm>(() => settingsFormFromSettings(settings));
const [saving, setSaving] = useState(false);
// Permission check after hooks
if (!canManageSettings) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-slate-400">You do not have permission to manage settings.</p>
</div>
);
}
async function handleSave() {
setSaving(true);
const result = await saveWholesaleSettings({
brandId,
requireApproval: form.requireApproval,
minOrderAmount: form.minOrderAmount ? Number(form.minOrderAmount) : undefined,
onlinePaymentEnabled: form.onlinePaymentEnabled,
wholesaleEnabled: form.wholesaleEnabled,
squareSyncEnabled: form.squareSyncEnabled,
pickupLocation: form.pickupLocation,
fobLocation: form.fobLocation,
fromEmail: form.fromEmail,
invoiceBusinessName: form.invoiceBusinessName,
invoiceBusinessAddress: form.invoiceBusinessAddress,
invoiceBusinessPhone: form.invoiceBusinessPhone,
invoiceBusinessWebsite: form.invoiceBusinessWebsite,
notificationRecipients: form.notificationRecipients,
});
setSaving(false);
if (result.success) {
onMsg("success", "Settings saved.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save settings.");
}
}
function setField<K extends keyof SettingsForm>(field: K, value: SettingsForm[K]) {
setForm(f => ({ ...f, [field]: value }));
}
// ── Notification Recipients helpers ────────────────────────────────────────
function addRecipient(email: string, name: string = "") {
setForm(f => ({
...f,
notificationRecipients: [
...f.notificationRecipients,
{ email, name, active: true },
],
}));
}
function removeRecipient(idx: number) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.filter((_: unknown, i: number) => i !== idx),
}));
}
function toggleRecipient(idx: number) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
i === idx ? { ...r, active: !r.active } : r
),
}));
}
function updateRecipientName(idx: number, name: string) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
i === idx ? { ...r, name } : r
),
}));
}
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Settings</h2>
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Require Approval</p>
<p className="text-sm text-[var(--admin-text-muted)]">New registrations must be manually approved.</p>
</div>
<button type="button"
onClick={() => setField("requireApproval", !form.requireApproval)}
aria-label="Toggle require approval"
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.requireApproval ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.requireApproval ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Wholesale Portal</p>
<p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand&apos;s storefront page.</p>
</div>
<button type="button"
onClick={() => setField("wholesaleEnabled", !form.wholesaleEnabled)}
aria-label="Toggle wholesale portal"
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.wholesaleEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.wholesaleEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent-light)]">
<svg className="h-4 w-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</div>
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Square Sync for Wholesale</p>
<p className="text-xs text-[var(--admin-text-muted)]">
When enabled, wholesale product changes are automatically pushed to Square.
{form.squareSyncEnabled
? " Auto-sync is active."
: " Auto-sync is disabled — changes will not be pushed to Square."}
</p>
</div>
</div>
<button type="button"
onClick={() => setField("squareSyncEnabled", !form.squareSyncEnabled)}
aria-label="Toggle square sync"
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.squareSyncEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.squareSyncEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-12-minimum-order-amount" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Minimum Order Amount ($)</label>
<input id="fld-12-minimum-order-amount" aria-label="No Minimum" type="number" value={form.minOrderAmount} onChange={e => setField("minOrderAmount", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" step="0.01" placeholder="No minimum" />
</div>
<div>
<label htmlFor="fld-13-from-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">From Email</label>
<input id="fld-13-from-email" aria-label="Email" type="email" value={form.fromEmail} onChange={e => setField("fromEmail", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-14-pickup-location" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Pickup Location</label>
<textarea id="fld-14-pickup-location" aria-label="Textarea" value={form.pickupLocation} onChange={e => setField("pickupLocation", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] font-mono" rows={3} />
</div>
<div className="col-span-2">
<label htmlFor="fld-15-fob-location" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">FOB Location</label>
<input id="fld-15-fob-location" aria-label="Input" value={form.fobLocation} onChange={e => setField("fobLocation", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-16-invoice-business-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Name</label>
<input id="fld-16-invoice-business-name" aria-label="Input" value={form.invoiceBusinessName} onChange={e => setField("invoiceBusinessName", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-17-invoice-business-address" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Address</label>
<textarea id="fld-17-invoice-business-address" aria-label="Textarea" value={form.invoiceBusinessAddress} onChange={e => setField("invoiceBusinessAddress", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
</div>
<div>
<label htmlFor="fld-18-invoice-business-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Phone</label>
<input id="fld-18-invoice-business-phone" aria-label="Input" value={form.invoiceBusinessPhone} onChange={e => setField("invoiceBusinessPhone", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label htmlFor="fld-19-invoice-business-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Email</label>
<input id="fld-19-invoice-business-email" aria-label="Email" type="email" value={form.invoiceBusinessEmail} onChange={e => setField("invoiceBusinessEmail", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="fld-20-invoice-business-website" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Website</label>
<input id="fld-20-invoice-business-website" aria-label="Https://" value={form.invoiceBusinessWebsite} onChange={e => setField("invoiceBusinessWebsite", e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="https://" />
</div>
{/* Team Notification Recipients */}
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1" htmlFor="fld-team-notification-recipients">Team Notification Recipients</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
These team members receive all wholesale notifications for this brand new orders,
deposits, fulfillments, price sheets, and pickup reminders. If no recipients are
active, the system falls back to any configured notification email on file.
</p>
{/* Empty state */}
{form.notificationRecipients.length === 0 && (
<div className="text-center py-6 mb-4 rounded-xl bg-white ring-1 ring-[var(--admin-border)]">
<p className="text-sm text-[var(--admin-text-muted)] mb-1">No recipients added yet.</p>
<p className="text-xs text-[var(--admin-text-muted)]">Add an email address below to get started.</p>
</div>
)}
{/* Recipients list */}
<div className="space-y-2 mb-4">
{form.notificationRecipients.map((r: NotificationRecipient, idx: number) => (
<div key={r.email || `recipient-${idx}`} className={`flex items-center gap-2 rounded-xl px-3 py-2.5 bg-white ring-1 ${r.active ? "ring-[var(--admin-border)]" : "ring-[var(--admin-border-light)] opacity-70"}`}>
<input id="fld-team-notification-recipients" aria-label="Checkbox"
type="checkbox" checked={r.active} onChange={() => toggleRecipient(idx)}
className="rounded border-[var(--admin-border)] mt-0.5" title={r.active ? "Active — receives notifications" : "Inactive"} />
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${r.active ? "text-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"}`}>{r.email}</p>
{r.name && <p className="text-xs text-[var(--admin-text-muted)] truncate">{r.name}</p>}
{r.notification_types && r.notification_types.length > 0 && (
<p className="text-xs text-[var(--admin-text-secondary)] mt-0.5" title="Per-type filtering coming soon">Types: {r.notification_types.join(", ")}</p>
)}
</div>
<input aria-label="Name (optional)"
type="text" placeholder="Name (optional)"
value={r.name ?? ""}
onChange={e => updateRecipientName(idx, e.target.value)}
className="rounded-xl border border-[var(--admin-border)] px-2.5 py-1.5 text-xs w-36 outline-none focus:border-[var(--admin-accent)]" />
<button type="button" onClick={() => removeRecipient(idx)}
aria-label="Remove recipient"
className="text-[var(--admin-danger)] hover:text-[var(--admin-danger)] text-xs font-medium px-2 py-1 rounded-lg hover:bg-[var(--admin-danger-light)] transition-colors"
title="Remove recipient">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{/* Add recipient */}
<AddRecipientForm onAdd={addRecipient} />
</div>
{/* Webhook Settings */}
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1" htmlFor="fld-webhook-integration">Webhook Integration</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
Send order events to external systems (Harvest Point, ERPs, etc.). Payload is signed
with HMAC-SHA256. Enable and configure the URL and secret below.
</p>
<WebhookSettingsSection brandId={brandId} onMsg={onMsg} />
</div>
</div>
<AdminButton onClick={handleSave} disabled={saving} variant="primary" isLoading={saving}>
{saving ? "Saving..." : "Save Settings"}
</AdminButton>
</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { AdminBadge } from "@/components/admin/design-system";
const STATUS_BADGE_MAP: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
pending: "warning",
awaiting_deposit: "info",
confirmed: "info",
fulfilled: "success",
};
const STATUS_BADGE_LABEL: Record<string, string> = {
pending: "Pending",
awaiting_deposit: "Awaiting Deposit",
confirmed: "Confirmed",
fulfilled: "Fulfilled",
};
export default function StatusBadge({ status }: { status: string }) {
return (
<AdminBadge variant={STATUS_BADGE_MAP[status] ?? "default"}>
{STATUS_BADGE_LABEL[status] ?? status}
</AdminBadge>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useState, useEffect } from "react";
import crypto from "crypto";
import { getWebhookSettings, saveWebhookSettings } from "@/actions/wholesale";
import { AdminButton } from "@/components/admin/design-system";
type WebhookSettings = { url: string; secret: string; enabled: boolean };
export default function WebhookSettingsSection({ brandId, onMsg }: {
brandId: string;
onMsg: (kind: "success" | "error", text: string) => void;
}) {
const [settings, setSettings] = useState<WebhookSettings>({ url: "", secret: "", enabled: false });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
getWebhookSettings(brandId).then((s: WebhookSettings | null) => {
if (s) {
setSettings({ url: s.url ?? "", secret: s.secret ?? "", enabled: s.enabled });
} else {
setSettings({ url: "", secret: "", enabled: false });
}
setLoading(false);
});
}, [brandId]);
async function handleSave() {
setSaving(true);
const result = await saveWebhookSettings({
brandId,
url: settings.url,
secret: settings.secret,
enabled: settings.enabled,
});
setSaving(false);
if (result.success) {
onMsg("success", "Webhook settings saved.");
} else {
onMsg("error", result.error ?? "Failed to save webhook settings.");
}
}
async function handleTestDispatch() {
if (!settings.url || !settings.secret) {
onMsg("error", "Enter both a webhook URL and signing secret before testing.");
return;
}
setTesting(true);
onMsg("success", "Sending test webhook...");
const testPayload = {
event: "order_created",
test: true,
brand_id: brandId,
message: "Test webhook from Route Commerce wholesale portal",
timestamp: new Date().toISOString(),
order_id: null,
};
const payloadString = JSON.stringify(testPayload);
const signature = crypto
.createHmac("sha256", settings.secret)
.update(payloadString)
.digest("hex");
try {
const res = await fetch(settings.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": `sha256=${signature}`,
"X-Webhook-Event": "order_created",
},
body: payloadString,
});
const responseText = await res.text().catch(() => "");
if (res.ok) {
onMsg("success", `Test delivered — ${res.status} response from your endpoint. Check wholesale_sync_log for the logged entry.`);
} else {
onMsg("error", `Webhook endpoint returned ${res.status}: ${responseText.slice(0, 120)}`);
}
} catch (err) {
onMsg("error", `Connection failed: ${err instanceof Error ? err.message : "Network error"}`);
}
setTesting(false);
}
if (loading) {
return <p className="text-sm text-slate-400">Loading webhook settings...</p>;
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Webhook Enabled</p>
<p className="text-xs text-[var(--admin-text-muted)]">When disabled, no events are queued or sent.</p>
</div>
<button type="button"
onClick={() => setSettings(s => ({ ...s, enabled: !s.enabled }))}
aria-label="Toggle webhook"
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${settings.enabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${settings.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div>
<label htmlFor="fld-21-webhook-url" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Webhook URL</label>
<input id="fld-21-webhook-url" aria-label="Https://your System.com/webhooks/wholesale"
type="url"
value={settings.url}
onChange={e => setSettings(s => ({ ...s, url: e.target.value }))}
placeholder="https://your-system.com/webhooks/wholesale"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div>
<label htmlFor="fld-22-signing-secret-hmac-sha256" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Signing Secret (HMAC-SHA256)</label>
<input id="fld-22-signing-secret-hmac-sha256" aria-label="Leave Blank To Keep Current Secret"
type="password"
value={settings.secret}
onChange={e => setSettings(s => ({ ...s, secret: e.target.value }))}
placeholder="Leave blank to keep current secret"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] font-mono"
/>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
The receiver should verify the <code className="bg-[var(--admin-bg-subtle)] px-1">X-Webhook-Signature</code> header using this secret.
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<AdminButton
onClick={handleSave}
disabled={saving}
variant="primary"
size="sm"
isLoading={saving}
>
{saving ? "Saving..." : "Save Webhook Settings"}
</AdminButton>
{settings.enabled && settings.url && (
<AdminButton
onClick={handleTestDispatch}
disabled={testing}
variant="secondary"
size="sm"
isLoading={testing}
>
{testing ? "Dispatching..." : "Send Test Webhook"}
</AdminButton>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
"use client";
import {
type WholesaleCustomer,
} from "@/actions/wholesale";
export type CustomerForm = {
companyName: string;
contactName: string;
email: string;
phone: string;
accountStatus: string;
creditLimit: number;
depositsEnabled: boolean;
depositThreshold: string;
depositPercentage: string;
orderEmail: string;
invoiceEmail: string;
adminNotes: string;
};
export const EMPTY_CUSTOMER_FORM: CustomerForm = {
companyName: "",
contactName: "",
email: "",
phone: "",
accountStatus: "active",
creditLimit: 0,
depositsEnabled: false,
depositThreshold: "",
depositPercentage: "",
orderEmail: "",
invoiceEmail: "",
adminNotes: "",
};
export function customerFormFromCustomer(c: WholesaleCustomer): CustomerForm {
return {
companyName: c.company_name ?? "",
contactName: c.contact_name ?? "",
email: c.email ?? "",
phone: c.phone ?? "",
accountStatus: c.account_status ?? "active",
creditLimit: Number(c.credit_limit),
depositsEnabled: c.deposits_enabled,
depositThreshold: c.deposit_threshold?.toString() ?? "",
depositPercentage: c.deposit_percentage?.toString() ?? "",
orderEmail: c.order_email ?? "",
invoiceEmail: c.invoice_email ?? "",
adminNotes: c.admin_notes ?? "",
};
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import {
type WholesaleOrder,
type WholesaleCustomer,
enqueueWholesaleNotification,
getWholesaleSettings,
markWholesaleOrderFulfilled,
} from "@/actions/wholesale";
export async function fulfillWithNotifications(
orderId: string,
orders: WholesaleOrder[],
customers: WholesaleCustomer[],
brandId: string,
): Promise<{ success: boolean; error?: string }> {
const result = await markWholesaleOrderFulfilled(orderId);
if (!result.success) return result;
const order = orders.find(o => o.id === orderId);
const customer = order ? customers.find(c => c.email === order.customer_email) : null;
if (order && customer) {
enqueueWholesaleNotification({
brandId: brandId,
customerId: customer.id,
orderId,
type: "order_fulfilled",
emailTo: customer.email,
subject: `Order ${order.invoice_number ?? orderId.slice(0, 8)} Ready for Pickup`,
bodyHtml: `
<h2>Your Order is Ready!</h2>
<p>Hi ${customer.company_name},</p>
<p>Your wholesale order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}.</p>
<p>Thank you for your business!</p>
`,
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}. Thank you!`,
});
const settings = await getWholesaleSettings(brandId);
const adminEmail = settings?.notification_email ?? settings?.from_email ?? settings?.invoice_business_email ?? null;
if (adminEmail) {
enqueueWholesaleNotification({
brandId: brandId,
customerId: customer.id,
orderId,
type: "order_fulfilled",
emailTo: adminEmail,
subject: `[Admin] Order Fulfilled — ${order.invoice_number ?? orderId.slice(0, 8)}`,
bodyHtml: `
<h2>Order Fulfilled</h2>
<p>Order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> for <strong>${customer.company_name}</strong> has been marked as fulfilled.</p>
${order.anticipated_pickup_date ? `<p><strong>Pickup Date:</strong> ${order.anticipated_pickup_date}</p>` : ""}
`,
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} for ${customer.company_name} has been fulfilled.`,
});
}
fetch(`/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
}
return { success: true };
}
function generateManifestHtml(orders: WholesaleOrder[], brandId: string): Promise<string> {
return fetch("/api/wholesale/manifest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, orders }),
}).then(r => r.text());
}
export function openManifestWindow(orders: WholesaleOrder[], brandId: string): void {
generateManifestHtml(orders, brandId).then(html => {
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const w = window.open(url, "_blank");
if (w) {
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} else {
URL.revokeObjectURL(url);
}
});
}
+566 -354
View File
@@ -1,14 +1,62 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useReducer, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import { useCart } from "@/context/CartContext";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import { getPublicStopsForBrand, checkStopProductAvailability, type PublicStop } from "@/actions/checkout";
import {
getPublicStopsForBrand,
checkStopProductAvailability,
type PublicStop,
} from "@/actions/checkout";
type Stop = PublicStop;
type State = {
stops: Stop[];
loadingStops: boolean;
showStopPicker: boolean;
incompatibleItems: string[];
availabilityError: boolean;
};
type Action =
| { type: "OPEN_PICKER" }
| { type: "CLOSE_PICKER" }
| { type: "SET_STOPS_LOADING"; value: boolean }
| { type: "SET_STOPS"; stops: Stop[] }
| { type: "RESET_PICKER_AND_AVAILABILITY" }
| { type: "SET_INCOMPATIBLE"; items: string[] }
| { type: "SET_AVAILABILITY_ERROR"; value: boolean };
const initialState: State = {
stops: [],
loadingStops: false,
showStopPicker: false,
incompatibleItems: [],
availabilityError: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "OPEN_PICKER":
return { ...state, showStopPicker: true };
case "CLOSE_PICKER":
return { ...state, showStopPicker: false };
case "SET_STOPS_LOADING":
return { ...state, loadingStops: action.value };
case "SET_STOPS":
return { ...state, stops: action.stops, loadingStops: false };
case "RESET_PICKER_AND_AVAILABILITY":
return { ...state, showStopPicker: false, availabilityError: false, incompatibleItems: [] };
case "SET_INCOMPATIBLE":
return { ...state, incompatibleItems: action.items };
case "SET_AVAILABILITY_ERROR":
return { ...state, availabilityError: action.value };
}
}
export default function CartClient() {
const {
cart,
@@ -21,98 +69,104 @@ export default function CartClient() {
removeFromCart,
} = useCart();
const [stops, setStops] = useState<Stop[]>([]);
const [loadingStops, setLoadingStops] = useState(false);
const [showStopPicker, setShowStopPicker] = useState(false);
const [incompatibleItems, setIncompatibleItems] = useState<string[]>([]);
const [availabilityError, setAvailabilityError] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed");
const hasShedPickupItems = cart.some(
(i) => i.fulfillment === "pickup" && i.pickup_type === "shed"
);
const hasStopPickupItems = cart.some(
(i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"
);
// Brand mismatch is a pure derivation of selectedStop + cartBrandId —
// compute it inline during render instead of mirroring it into state
// and syncing via useEffect. This also removes the "fake event handler"
// anti-pattern flagged by react-doctor.
// and syncing via useEffect.
const stopBrandMismatch = !!(
selectedStop?.brand_id &&
cartBrandId &&
selectedStop.brand_id !== cartBrandId
);
// Stops are fetched on demand when the picker opens. The handler
// below owns the side effect — no useEffect watching showStopPicker.
const handleOpenStopPicker = useCallback(() => {
setShowStopPicker(true);
if (hasPickupItems && cartBrandId && stops.length === 0) {
setLoadingStops(true);
dispatch({ type: "OPEN_PICKER" });
if (hasPickupItems && cartBrandId && state.stops.length === 0) {
dispatch({ type: "SET_STOPS_LOADING", value: true });
getPublicStopsForBrand(cartBrandId)
.then((data) => setStops(data ?? []))
.catch(() => setStops([]))
.finally(() => setLoadingStops(false));
.then((data) => dispatch({ type: "SET_STOPS", stops: data ?? [] }))
.catch(() => dispatch({ type: "SET_STOPS", stops: [] }));
}
}, [hasPickupItems, cartBrandId, stops.length]);
}, [hasPickupItems, cartBrandId, state.stops.length]);
const handleStopSelect = useCallback((stop: Stop) => {
setSelectedStop(stop);
setShowStopPicker(false);
setAvailabilityError(false);
setIncompatibleItems([]);
const handleStopSelect = useCallback(
(stop: Stop) => {
setSelectedStop(stop);
dispatch({ type: "RESET_PICKER_AND_AVAILABILITY" });
if (hasPickupItems) {
const pickupProductIds: string[] = [];
for (const i of cart) {
if (i.fulfillment === "pickup") pickupProductIds.push(i.id);
if (hasPickupItems) {
const pickupProductIds: string[] = [];
for (const i of cart) {
if (i.fulfillment === "pickup") pickupProductIds.push(i.id);
}
checkStopProductAvailability(stop.id, pickupProductIds)
.then((data) => {
const unavailable: string[] = [];
for (const row of data ?? []) {
if (row.is_available === false) unavailable.push(row.product_id);
}
dispatch({ type: "SET_INCOMPATIBLE", items: unavailable });
})
.catch(() => {
dispatch({ type: "SET_AVAILABILITY_ERROR", value: true });
dispatch({ type: "SET_INCOMPATIBLE", items: [] });
});
}
checkStopProductAvailability(stop.id, pickupProductIds)
.then((data) => {
const unavailable: string[] = [];
for (const row of data ?? []) {
if (row.is_available === false) unavailable.push(row.product_id);
}
setIncompatibleItems(unavailable);
})
.catch(() => {
setAvailabilityError(true);
setIncompatibleItems([]);
});
}
}, [cart, hasPickupItems, setSelectedStop]);
},
[cart, hasPickupItems, setSelectedStop]
);
const handleCheckoutClick = useCallback(() => {
if (cart.length === 0) return;
if (stopBrandMismatch) { setSelectedStop(null); return; }
if (hasStopPickupItems && !selectedStop) { handleOpenStopPicker(); return; }
if (incompatibleItems.length > 0) { return; }
if (stopBrandMismatch) {
setSelectedStop(null);
return;
}
if (hasStopPickupItems && !selectedStop) {
handleOpenStopPicker();
return;
}
if (state.incompatibleItems.length > 0) return;
window.location.href = "/checkout";
}, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]);
}, [
cart.length,
stopBrandMismatch,
hasStopPickupItems,
selectedStop,
state.incompatibleItems,
setSelectedStop,
handleOpenStopPicker,
]);
const stopPickerDialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!showStopPicker) return;
if (!state.showStopPicker) return;
const dialog = stopPickerDialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
setShowStopPicker(false);
dispatch({ type: "CLOSE_PICKER" });
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [showStopPicker]);
}, [state.showStopPicker]);
return (
<div className="min-h-screen relative">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" aria-hidden="true" />
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
</div>
<CartBackground />
<StorefrontHeader brandName="Your Cart" brandSlug="tuxedo" />
<main className="px-6 py-12 relative">
@@ -121,312 +175,470 @@ export default function CartClient() {
<h1 className="text-4xl font-semibold tracking-tight text-white">Shopping Cart</h1>
<p className="mt-3 text-zinc-400">Review your products before checkout.</p>
{/* Brand mismatch alert */}
{stopBrandMismatch && (
<div className="mt-5 glass-card p-5 border border-red-500/20" role="alert">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20">
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Pickup stop is from a different store</p>
<p className="mt-1 text-sm text-zinc-400">Your cart was updated. Please select a new pickup stop.</p>
<button type="button"
onClick={() => { setSelectedStop(null); handleOpenStopPicker(); }}
className="mt-3 rounded-xl bg-red-500 hover:bg-red-400 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-red-500/20"
>
Choose Correct Stop
</button>
</div>
</div>
</div>
)}
<HeaderAlerts
stopBrandMismatch={stopBrandMismatch}
hasShedPickupItems={hasShedPickupItems}
hasStopPickupItems={hasStopPickupItems}
selectedStop={selectedStop}
cartBrandId={cartBrandId}
incompatibleItems={state.incompatibleItems}
availabilityError={state.availabilityError}
cart={cart}
onChooseStop={handleOpenStopPicker}
onChooseCorrectStop={() => {
setSelectedStop(null);
handleOpenStopPicker();
}}
/>
{/* Shed pickup info */}
{hasShedPickupItems && (
<div className="mt-5 glass-card p-5 border border-emerald-500/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Shed Pickup</p>
<p className="mt-1 text-sm text-zinc-400">
{cart.flatMap((i) =>
i.fulfillment === "pickup" && i.pickup_type === "shed"
? [i.description || i.name]
: []
).join(", ")}
</p>
</div>
</div>
</div>
)}
<StopPickerDialog
dialogRef={stopPickerDialogRef}
open={state.showStopPicker}
loadingStops={state.loadingStops}
stops={state.stops}
onSelect={handleStopSelect}
onClose={() => dispatch({ type: "CLOSE_PICKER" })}
/>
{/* Stop picker prompt */}
{hasStopPickupItems && !selectedStop && !stopBrandMismatch && cartBrandId && (
<div className="mt-5 glass-card p-5 border border-amber-500/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Pickup stop needed</p>
<p className="mt-1 text-sm text-zinc-400">Your cart has pickup items. Select a stop before checkout.</p>
<button type="button"
onClick={handleOpenStopPicker}
className="mt-3 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-amber-500/20"
aria-label="Choose pickup stop"
>
Choose Pickup Stop
</button>
</div>
</div>
</div>
)}
{/* Incompatible items warning */}
{incompatibleItems.length > 0 && (
<div className="mt-4 glass-card p-5 border border-red-500/20" role="alert">
<p className="font-semibold text-white">Some items are not available at this stop:</p>
<ul className="mt-2 space-y-1" aria-label="Unavailable items">
{incompatibleItems.map((id) => {
const item = cart.find((i) => i.id === id);
return <li key={id} className="text-sm text-zinc-400"> {item?.name}</li>;
})}
</ul>
<p className="mt-2 text-sm text-zinc-500">Please remove these items or choose a different stop.</p>
</div>
)}
{/* Availability error */}
{availabilityError && (
<div className="mt-4 glass-card p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Could not verify product availability</p>
<p className="mt-1 text-sm text-zinc-400">Stop was selected. Checkout will validate product-stop assignment.</p>
</div>
</div>
</div>
)}
{/* Stop picker modal */}
{showStopPicker && (
<dialog
ref={stopPickerDialogRef}
aria-labelledby="stop-picker-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/60"
>
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
<p className="mt-2 text-sm text-zinc-400">Select a stop for your pickup items.</p>
{loadingStops ? (
<div className="mt-4 flex items-center justify-center gap-2 py-4" aria-live="polite">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-600 border-t-white" aria-hidden="true" />
<span className="text-sm text-zinc-400">Loading stops...</span>
</div>
) : stops.length === 0 ? (
<p className="mt-4 text-sm text-zinc-400 text-center">No stops available.</p>
) : (
<div className="mt-4 space-y-2">
{stops.map((stop) => (
<button type="button"
key={stop.id}
onClick={() => handleStopSelect(stop)}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left hover:bg-white/10 transition-all text-white"
>
<p className="font-medium">{stop.city}, {stop.state}</p>
<p className="mt-0.5 text-sm text-zinc-400">{stop.date} · {stop.time} · {stop.location}</p>
</button>
))}
</div>
)}
<button type="button"
onClick={() => setShowStopPicker(false)}
className="mt-5 w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</dialog>
)}
{/* Cart items */}
<div className="mt-10 space-y-4">
{cart.length === 0 ? (
<div className="glass-card p-8 text-center">
<p className="text-zinc-500">Your cart is empty.</p>
<Link href="/" className="mt-4 inline-block text-emerald-400 hover:text-emerald-300 transition-colors"> Continue shopping</Link>
</div>
) : (
cart.map((item) => (
<article key={item.id} className="glass-card p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-white">{item.name}</h2>
<p className="mt-1 text-sm text-zinc-400">
{item.fulfillment === "pickup" ? (
<span className="inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
Shipping
</span>
)}
</p>
</div>
<fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label={`Quantity controls for ${item.name}`}>
<button type="button"
onClick={() => decreaseQuantity(item.id)}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/10 text-white hover:bg-white/20 active:scale-95 transition-all text-xl font-medium"
aria-label={`Decrease quantity of ${item.name}`}
></button>
<span className="w-10 text-center font-semibold text-white" aria-label={`Quantity: ${item.quantity}`}>{item.quantity}</span>
<button type="button"
onClick={() => increaseQuantity(item.id)}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 active:scale-95 transition-all text-xl font-medium shadow-lg shadow-emerald-500/20"
aria-label={`Increase quantity of ${item.name}`}
>+</button>
</fieldset>
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
<p className="text-sm font-semibold text-white" aria-label={`Item total: $${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}`}>
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
</p>
<button type="button"
onClick={() => removeFromCart(item.id)}
className="text-sm text-zinc-500 hover:text-red-400 transition-colors"
aria-label={`Remove ${item.name} from cart`}
>
Remove
</button>
</div>
</article>
))
)}
</div>
<CartItemsList
cart={cart}
onDecrease={decreaseQuantity}
onIncrease={increaseQuantity}
onRemove={removeFromCart}
/>
</div>
{/* Order summary sidebar */}
<aside aria-label="Order summary">
<div className="glass-card p-6 sticky top-6">
<h2 className="text-xl font-semibold text-white">Order Summary</h2>
{hasPickupItems && (
<div className="mt-5">
{hasShedPickupItems && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Shed Pickup
</p>
<p className="mt-1.5 text-xs text-zinc-400">
{cart.flatMap((i) =>
i.fulfillment === "pickup" && i.pickup_type === "shed"
? [i.description || i.name]
: []
).join(", ")}
</p>
</div>
)}
{selectedStop && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup: {selectedStop.city}, {selectedStop.state}
</p>
<p className="mt-1.5 text-xs text-zinc-400">{selectedStop.date} · {selectedStop.time}</p>
<p className="mt-0.5 text-xs text-zinc-400">{selectedStop.location}</p>
<button type="button"
onClick={handleOpenStopPicker}
className="mt-2.5 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
aria-label="Change pickup stop"
>
Change stop
</button>
</div>
)}
{!selectedStop && hasStopPickupItems && (
<button type="button"
onClick={handleOpenStopPicker}
className="w-full rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-left text-sm text-amber-400 hover:bg-amber-500/20 transition-all"
aria-label="Select pickup stop"
>
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /></svg>
No pickup stop selected click to choose
</button>
)}
</div>
)}
<div className="mt-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
<span className="text-sm font-semibold text-white">${subtotal.toFixed(2)}</span>
</div>
<div className="border-t border-white/5 pt-3">
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">Total</span>
<span className="text-2xl font-bold text-white">${subtotal.toFixed(2)}</span>
</div>
</div>
</div>
<button type="button"
onClick={handleCheckoutClick}
disabled={
cart.length === 0 ||
(hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0))
}
className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5"
aria-label={
cart.length === 0
? "Your cart is empty. Add a product to continue."
: incompatibleItems.length > 0
? "Remove incompatible items first to proceed to checkout"
: !selectedStop && hasStopPickupItems
? "Select pickup stop first to proceed to checkout"
: "Continue to checkout"
}
>
{cart.length === 0
? "Cart is Empty"
: incompatibleItems.length > 0
? "Remove Incompatible Items First"
: !selectedStop && hasStopPickupItems
? "Select Pickup Stop First"
: "Continue to Checkout"}
</button>
<Link
href="/"
className="mt-4 block w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
aria-label="Continue shopping"
>
Continue Shopping
</Link>
</div>
</aside>
<OrderSummary
cart={cart}
subtotal={subtotal}
selectedStop={selectedStop}
hasPickupItems={hasPickupItems}
hasShedPickupItems={hasShedPickupItems}
hasStopPickupItems={hasStopPickupItems}
incompatibleItems={state.incompatibleItems}
onChangeStop={handleOpenStopPicker}
onCheckout={handleCheckoutClick}
/>
</div>
</main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
</div>
);
}
function CartBackground() {
return (
<>
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" aria-hidden="true" />
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
</div>
</>
);
}
type HeaderAlertsProps = {
stopBrandMismatch: boolean;
hasShedPickupItems: boolean;
hasStopPickupItems: boolean;
selectedStop: Stop | null;
cartBrandId: string | null | undefined;
incompatibleItems: string[];
availabilityError: boolean;
cart: ReturnType<typeof useCart>["cart"];
onChooseStop: () => void;
onChooseCorrectStop: () => void;
};
function HeaderAlerts({
stopBrandMismatch,
hasShedPickupItems,
hasStopPickupItems,
selectedStop,
cartBrandId,
incompatibleItems,
availabilityError,
cart,
onChooseStop,
onChooseCorrectStop,
}: HeaderAlertsProps) {
return (
<>
{stopBrandMismatch && (
<div className="mt-5 glass-card p-5 border border-red-500/20" role="alert">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20">
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Pickup stop is from a different store</p>
<p className="mt-1 text-sm text-zinc-400">Your cart was updated. Please select a new pickup stop.</p>
<button type="button"
onClick={onChooseCorrectStop}
className="mt-3 rounded-xl bg-red-500 hover:bg-red-400 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-red-500/20"
>
Choose Correct Stop
</button>
</div>
</div>
</div>
)}
{hasShedPickupItems && (
<div className="mt-5 glass-card p-5 border border-emerald-500/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Shed Pickup</p>
<p className="mt-1 text-sm text-zinc-400">
{cart
.flatMap((i) =>
i.fulfillment === "pickup" && i.pickup_type === "shed"
? [i.description || i.name]
: []
)
.join(", ")}
</p>
</div>
</div>
</div>
)}
{hasStopPickupItems && !selectedStop && !stopBrandMismatch && cartBrandId && (
<div className="mt-5 glass-card p-5 border border-amber-500/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Pickup stop needed</p>
<p className="mt-1 text-sm text-zinc-400">Your cart has pickup items. Select a stop before checkout.</p>
<button type="button"
onClick={onChooseStop}
className="mt-3 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-amber-500/20"
aria-label="Choose pickup stop"
>
Choose Pickup Stop
</button>
</div>
</div>
</div>
)}
{incompatibleItems.length > 0 && (
<div className="mt-4 glass-card p-5 border border-red-500/20" role="alert">
<p className="font-semibold text-white">Some items are not available at this stop:</p>
<ul className="mt-2 space-y-1" aria-label="Unavailable items">
{incompatibleItems.map((id) => {
const item = cart.find((i) => i.id === id);
return <li key={id} className="text-sm text-zinc-400"> {item?.name}</li>;
})}
</ul>
<p className="mt-2 text-sm text-zinc-500">Please remove these items or choose a different stop.</p>
</div>
)}
{availabilityError && (
<div className="mt-4 glass-card p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Could not verify product availability</p>
<p className="mt-1 text-sm text-zinc-400">Stop was selected. Checkout will validate product-stop assignment.</p>
</div>
</div>
</div>
)}
</>
);
}
type StopPickerDialogProps = {
dialogRef: React.RefObject<HTMLDialogElement | null>;
open: boolean;
loadingStops: boolean;
stops: Stop[];
onSelect: (stop: Stop) => void;
onClose: () => void;
};
function StopPickerDialog({
dialogRef,
open,
loadingStops,
stops,
onSelect,
onClose,
}: StopPickerDialogProps) {
if (!open) return null;
return (
<dialog
ref={dialogRef}
aria-labelledby="stop-picker-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/60"
>
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
<p className="mt-2 text-sm text-zinc-400">Select a stop for your pickup items.</p>
{loadingStops ? (
<div className="mt-4 flex items-center justify-center gap-2 py-4" aria-live="polite">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-600 border-t-white" aria-hidden="true" />
<span className="text-sm text-zinc-400">Loading stops...</span>
</div>
) : stops.length === 0 ? (
<p className="mt-4 text-sm text-zinc-400 text-center">No stops available.</p>
) : (
<div className="mt-4 space-y-2">
{stops.map((stop) => (
<button type="button"
key={stop.id}
onClick={() => onSelect(stop)}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left hover:bg-white/10 transition-all text-white"
>
<p className="font-medium">{stop.city}, {stop.state}</p>
<p className="mt-0.5 text-sm text-zinc-400">{stop.date} · {stop.time} · {stop.location}</p>
</button>
))}
</div>
)}
<button type="button"
onClick={onClose}
className="mt-5 w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</dialog>
);
}
type CartItemsListProps = {
cart: ReturnType<typeof useCart>["cart"];
onDecrease: (id: string) => void;
onIncrease: (id: string) => void;
onRemove: (id: string) => void;
};
function CartItemsList({ cart, onDecrease, onIncrease, onRemove }: CartItemsListProps) {
return (
<div className="mt-10 space-y-4">
{cart.length === 0 ? (
<div className="glass-card p-8 text-center">
<p className="text-zinc-500">Your cart is empty.</p>
<Link href="/" className="mt-4 inline-block text-emerald-400 hover:text-emerald-300 transition-colors"> Continue shopping</Link>
</div>
) : (
cart.map((item) => (
<CartItemRow
key={item.id}
item={item}
onDecrease={onDecrease}
onIncrease={onIncrease}
onRemove={onRemove}
/>
))
)}
</div>
);
}
type CartItemRowProps = {
item: ReturnType<typeof useCart>["cart"][number];
onDecrease: (id: string) => void;
onIncrease: (id: string) => void;
onRemove: (id: string) => void;
};
function CartItemRow({ item, onDecrease, onIncrease, onRemove }: CartItemRowProps) {
const priceNumber = Number(item.price.replace("$", ""));
const itemTotal = (priceNumber * item.quantity).toFixed(2);
return (
<article className="glass-card p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-white">{item.name}</h2>
<p className="mt-1 text-sm text-zinc-400">
{item.fulfillment === "pickup" ? (
<span className="inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
Shipping
</span>
)}
</p>
</div>
<fieldset className="flex items-center gap-3 border-0 p-0 m-0" aria-label={`Quantity controls for ${item.name}`}>
<button type="button"
onClick={() => onDecrease(item.id)}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/10 text-white hover:bg-white/20 active:scale-95 transition-all text-xl font-medium"
aria-label={`Decrease quantity of ${item.name}`}
></button>
<span className="w-10 text-center font-semibold text-white" aria-label={`Quantity: ${item.quantity}`}>{item.quantity}</span>
<button type="button"
onClick={() => onIncrease(item.id)}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 active:scale-95 transition-all text-xl font-medium shadow-lg shadow-emerald-500/20"
aria-label={`Increase quantity of ${item.name}`}
>+</button>
</fieldset>
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
<p className="text-sm font-semibold text-white" aria-label={`Item total: $${itemTotal}`}>
${itemTotal}
</p>
<button type="button"
onClick={() => onRemove(item.id)}
className="text-sm text-zinc-500 hover:text-red-400 transition-colors"
aria-label={`Remove ${item.name} from cart`}
>
Remove
</button>
</div>
</article>
);
}
type OrderSummaryProps = {
cart: ReturnType<typeof useCart>["cart"];
subtotal: number;
selectedStop: Stop | null;
hasPickupItems: boolean;
hasShedPickupItems: boolean;
hasStopPickupItems: boolean;
incompatibleItems: string[];
onChangeStop: () => void;
onCheckout: () => void;
};
function OrderSummary({
cart,
subtotal,
selectedStop,
hasPickupItems,
hasShedPickupItems,
hasStopPickupItems,
incompatibleItems,
onChangeStop,
onCheckout,
}: OrderSummaryProps) {
const checkoutDisabled =
cart.length === 0 ||
(hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0));
return (
<aside aria-label="Order summary">
<div className="glass-card p-6 sticky top-6">
<h2 className="text-xl font-semibold text-white">Order Summary</h2>
{hasPickupItems && (
<div className="mt-5">
{hasShedPickupItems && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Shed Pickup
</p>
<p className="mt-1.5 text-xs text-zinc-400">
{cart
.flatMap((i) =>
i.fulfillment === "pickup" && i.pickup_type === "shed"
? [i.description || i.name]
: []
)
.join(", ")}
</p>
</div>
)}
{selectedStop && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup: {selectedStop.city}, {selectedStop.state}
</p>
<p className="mt-1.5 text-xs text-zinc-400">{selectedStop.date} · {selectedStop.time}</p>
<p className="mt-0.5 text-xs text-zinc-400">{selectedStop.location}</p>
<button type="button"
onClick={onChangeStop}
className="mt-2.5 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
aria-label="Change pickup stop"
>
Change stop
</button>
</div>
)}
{!selectedStop && hasStopPickupItems && (
<button type="button"
onClick={onChangeStop}
className="w-full rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-left text-sm text-amber-400 hover:bg-amber-500/20 transition-all"
aria-label="Select pickup stop"
>
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /></svg>
No pickup stop selected click to choose
</button>
)}
</div>
)}
<div className="mt-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
<span className="text-sm font-semibold text-white">${subtotal.toFixed(2)}</span>
</div>
<div className="border-t border-white/5 pt-3">
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">Total</span>
<span className="text-2xl font-bold text-white">${subtotal.toFixed(2)}</span>
</div>
</div>
</div>
<button type="button"
onClick={onCheckout}
disabled={checkoutDisabled}
className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5"
aria-label={
cart.length === 0
? "Your cart is empty. Add a product to continue."
: incompatibleItems.length > 0
? "Remove incompatible items first to proceed to checkout"
: !selectedStop && hasStopPickupItems
? "Select pickup stop first to proceed to checkout"
: "Continue to checkout"
}
>
{cart.length === 0
? "Cart is Empty"
: incompatibleItems.length > 0
? "Remove Incompatible Items First"
: !selectedStop && hasStopPickupItems
? "Select Pickup Stop First"
: "Continue to Checkout"}
</button>
<Link
href="/"
className="mt-4 block w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
aria-label="Continue shopping"
>
Continue Shopping
</Link>
</div>
</aside>
);
}
+525 -377
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useEffect, useCallback, useReducer } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useCart } from "@/context/CartContext";
@@ -13,6 +13,73 @@ import StripeExpressCheckout, {
type CheckoutItem as ExpressItem,
} from "@/components/storefront/StripeExpressCheckout";
// ---- useReducer state ----
type State = {
stops: StopInfo[];
customerName: string;
customerEmail: string;
customerPhone: string;
shippingState: string;
shippingPostal: string;
shippingCity: string;
hostedLoading: boolean;
hostedError: string | null;
idempotencyKey: string;
};
type Action =
| { type: "SET_STOPS"; stops: StopInfo[] }
| { type: "SET_NAME"; value: string }
| { type: "SET_EMAIL"; value: string }
| { type: "SET_PHONE"; value: string }
| { type: "SET_SHIPPING_STATE"; value: string }
| { type: "SET_SHIPPING_POSTAL"; value: string }
| { type: "SET_SHIPPING_CITY"; value: string }
| { type: "SET_HOSTED_LOADING"; value: boolean }
| { type: "SET_HOSTED_ERROR"; value: string | null };
function initialState(): State {
return {
stops: [],
customerName: "",
customerEmail: "",
customerPhone: "",
shippingState: "",
shippingPostal: "",
shippingCity: "",
hostedLoading: false,
hostedError: null,
// Stable idempotency key per checkout session — survives retries.
// Use a lazy initializer with a guard so SSR returns "" and the
// client fills in a UUID on first render. No useEffect needed.
idempotencyKey:
typeof window === "undefined" ? "" : crypto.randomUUID(),
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_STOPS":
return { ...state, stops: action.stops };
case "SET_NAME":
return { ...state, customerName: action.value };
case "SET_EMAIL":
return { ...state, customerEmail: action.value };
case "SET_PHONE":
return { ...state, customerPhone: action.value };
case "SET_SHIPPING_STATE":
return { ...state, shippingState: action.value };
case "SET_SHIPPING_POSTAL":
return { ...state, shippingPostal: action.value };
case "SET_SHIPPING_CITY":
return { ...state, shippingCity: action.value };
case "SET_HOSTED_LOADING":
return { ...state, hostedLoading: action.value };
case "SET_HOSTED_ERROR":
return { ...state, hostedError: action.value };
}
}
export default function CheckoutClient() {
const cartContext = useCart();
const cart = cartContext.cart;
@@ -21,28 +88,7 @@ export default function CheckoutClient() {
const setSelectedStop = cartContext.setSelectedStop;
const cartBrandId = cartContext.cartBrandId;
const router = useRouter();
const [stops, setStops] = useState<StopInfo[]>([]);
// Controlled form state — passed down to StripeExpressCheckout so the
// Express + Card paths know who the buyer is and where to ship / pick up.
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [shippingState, setShippingState] = useState("");
const [shippingPostal, setShippingPostal] = useState("");
const [shippingCity, setShippingCity] = useState("");
// Hosted-checkout (legacy Stripe Checkout) fallback state
const [hostedLoading, setHostedLoading] = useState(false);
const [hostedError, setHostedError] = useState<string | null>(null);
// Stable idempotency key per checkout session — survives retries.
// Use a lazy initializer with a guard so SSR returns "" and the
// client fills in a UUID on first render. No useEffect needed.
const [idempotencyKey, setIdempotencyKey] = useState<string>(() => {
if (typeof window === "undefined") return "";
return crypto.randomUUID();
});
const [state, dispatch] = useReducer(reducer, undefined, initialState);
useEffect(() => {
// Initial stops load on mount (and on brand change). Cleanup
@@ -52,17 +98,16 @@ export default function CheckoutClient() {
let cancelled = false;
getPublicStopsForBrand(cartBrandId)
.then((data) => {
if (!cancelled) setStops(data ?? []);
if (!cancelled) dispatch({ type: "SET_STOPS", stops: data ?? [] });
})
.catch(() => {
if (!cancelled) setStops([]);
if (!cancelled) dispatch({ type: "SET_STOPS", stops: [] });
});
return () => {
cancelled = true;
};
}, [cartBrandId]);
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
const hasStopPickupItems = cart.some(
(i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"
@@ -84,17 +129,17 @@ export default function CheckoutClient() {
return;
}
if (!customerEmail.trim()) {
setHostedError("Please enter your email before continuing to checkout.");
if (!state.customerEmail.trim()) {
dispatch({ type: "SET_HOSTED_ERROR", value: "Please enter your email before continuing to checkout." });
return;
}
if (hasStopPickupItems && !selectedStop) {
setHostedError("Please select a pickup location.");
dispatch({ type: "SET_HOSTED_ERROR", value: "Please select a pickup location." });
return;
}
setHostedLoading(true);
setHostedError(null);
dispatch({ type: "SET_HOSTED_LOADING", value: true });
dispatch({ type: "SET_HOSTED_ERROR", value: null });
const items = cart.map((item) => ({
id: item.id,
@@ -113,9 +158,9 @@ export default function CheckoutClient() {
sessionStorage.setItem(
"pending_checkout:v1",
JSON.stringify({
customerName,
customerEmail,
customerPhone,
customerName: state.customerName,
customerEmail: state.customerEmail,
customerPhone: state.customerPhone,
stopId: selectedStop?.id ?? null,
items: items.map((item) => ({
...item,
@@ -123,9 +168,9 @@ export default function CheckoutClient() {
})),
cartBrandId,
shippingAddress: hasShipItems
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
? { state: state.shippingState, postal_code: state.shippingPostal, city: state.shippingCity }
: undefined,
idempotencyKey: idempotencyKey,
idempotencyKey: state.idempotencyKey,
})
);
}
@@ -138,46 +183,31 @@ export default function CheckoutClient() {
cancelUrl
);
if (!stripeResult.success || !stripeResult.url) {
setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
setHostedLoading(false);
dispatch({ type: "SET_HOSTED_ERROR", value: stripeResult.error ?? "Failed to initiate payment. Please try again." });
dispatch({ type: "SET_HOSTED_LOADING", value: false });
return;
}
window.location.href = stripeResult.url;
}, [
cart,
customerName,
customerEmail,
customerPhone,
state.customerName,
state.customerEmail,
state.customerPhone,
state.shippingState,
state.shippingPostal,
state.shippingCity,
state.idempotencyKey,
selectedStop,
shippingState,
shippingPostal,
shippingCity,
cartBrandId,
hasShipItems,
hasStopPickupItems,
cartBrandId,
router,
setSelectedStop,
idempotencyKey,
]);
if (cart.length === 0) {
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
<Link
href="/"
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
>
Back to storefront
</Link>
</div>
</main>
</div>
);
return <EmptyCartView />;
}
// Map cart → StripeExpressCheckout item shape
@@ -194,324 +224,442 @@ export default function CheckoutClient() {
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
{/* LEFT — Form + Embedded Stripe Elements */}
<div>
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
<p className="mt-3 text-slate-600">
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure we never see them.
</p>
{/* Error alert (shared across both payment paths) */}
{(hostedError) && (
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{hostedError}</span>
</div>
</div>
)}
<div className="mt-8 space-y-6">
{/* Customer Information — controlled inputs feed the embedded
Stripe Elements above. The wallet (Apple Pay) will overwrite
name/email at confirm time, but we still collect them here
so the order row has them in case the wallet omits them. */}
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">Your details</legend>
<p className="mt-1 text-xs text-slate-500">
Used for the order confirmation. Apple Pay / Google Pay will fill these in for you.
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
Full Name
</label>
<input aria-label="Jane Smith"
id="customer_name"
name="customer_name"
autoComplete="name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="Jane Smith"
/>
</div>
<div>
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
Email <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input aria-label="Jane@example.com"
id="customer_email"
name="customer_email"
type="email"
autoComplete="email"
required
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="jane@example.com"
aria-required="true"
/>
</div>
<div>
<label htmlFor="customer_phone" className="block text-sm font-medium text-slate-700">
Phone <span className="text-slate-400 text-xs">(optional)</span>
</label>
<input aria-label="(555) 555 5555"
id="customer_phone"
name="customer_phone"
type="tel"
autoComplete="tel"
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="(555) 555-5555"
/>
</div>
</div>
</fieldset>
{/* Shed Pickup notice */}
{hasShedPickupItems && (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
<p className="mt-2 text-sm text-slate-600">
{cart.flatMap((i) =>
i.fulfillment === "pickup" && i.pickup_type === "shed"
? [i.description || i.name]
: []
).join(", ")}
</p>
<p className="mt-1 text-xs text-slate-500">Pickup location details are included in your order confirmation.</p>
</fieldset>
)}
{/* Stop Pickup */}
{hasStopPickupItems && (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">
Pickup Location <span className="text-red-500" aria-hidden="true">*</span>
</legend>
{selectedStop ? (
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<p className="font-medium text-emerald-900">
<svg className="inline h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
{selectedStop.city}, {selectedStop.state}
</p>
<p className="mt-1 text-sm text-emerald-700">
{selectedStop.date} · {selectedStop.location}
</p>
<button
type="button"
onClick={() => setSelectedStop(null)}
className="mt-2 text-xs text-emerald-600 hover:text-emerald-800 underline transition-colors"
aria-label="Change pickup stop"
>
Change pickup stop
</button>
</div>
) : (
<div className="mt-4">
<label htmlFor="stop_id" className="block text-sm font-medium text-slate-700 mb-2">
Select pickup stop <span className="text-red-500" aria-hidden="true">*</span>
</label>
<select aria-label="Stop Id"
id="stop_id"
name="stop_id"
required
value={(selectedStop as StopInfo | null)?.id ?? ""}
onChange={(e) => {
const found = stops.find((s) => s.id === e.target.value) ?? null;
setSelectedStop(found as StopInfo | null);
}}
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
aria-required="true"
>
<option value="">Select a stop...</option>
{stops.map((stop) => (
<option key={stop.id} value={stop.id}>
{stop.city}, {stop.state} {stop.date} @ {stop.location}
</option>
))}
</select>
</div>
)}
</fieldset>
)}
{/* Shipping Address */}
{hasShipItems && (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">Shipping Address</legend>
<p className="mt-1 text-sm text-slate-500">
Enter your shipping details for delivery.
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-3">
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
Street Address
</label>
<input aria-label="123 Main St"
id="shipping_address"
name="shipping_address"
autoComplete="street-address"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="123 Main St"
/>
</div>
<div>
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
City
</label>
<input aria-label="Shipping City"
id="shipping_city"
name="shipping_city"
autoComplete="address-level2"
value={shippingCity}
onChange={(e) => setShippingCity(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
</div>
<div>
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
State
</label>
<input aria-label="NC"
id="shipping_state"
name="shipping_state"
autoComplete="address-level1"
maxLength={2}
value={shippingState}
onChange={(e) => setShippingState(e.target.value.toUpperCase())}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
placeholder="NC"
/>
</div>
<div>
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
ZIP Code
</label>
<input aria-label="28147"
id="shipping_postal_code"
name="shipping_postal_code"
autoComplete="postal-code"
value={shippingPostal}
onChange={(e) => setShippingPostal(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="28147"
/>
</div>
</div>
</fieldset>
)}
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
<div data-testid="stripe-express-checkout">
<StripeExpressCheckout
items={expressItems}
brandId={cartBrandId}
customerName={customerName}
customerEmail={customerEmail}
customerPhone={customerPhone}
selectedStop={selectedStop as StopInfo | null}
shippingState={shippingState}
shippingPostal={shippingPostal}
shippingCity={shippingCity}
checkoutSessionKey={idempotencyKey || undefined}
onHostedCheckout={() => void handleHostedCheckout()}
/>
</div>
{/* Hosted-checkout fallback (legacy Stripe Checkout) */}
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
<p className="text-xs text-slate-500 mb-2 text-center">
Prefer Stripes hosted page? Tap below.
</p>
<button
type="button"
onClick={() => void handleHostedCheckout()}
disabled={hostedLoading}
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
>
{hostedLoading ? (
<>
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
Redirecting to Stripe
</>
) : (
<>Use secure hosted checkout </>
)}
</button>
</div>
</div>
</div>
{/* Order Summary */}
<aside aria-label="Order summary">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
<div className="mt-4 space-y-3">
{cart.map((item) => (
<div key={item.id} className="flex justify-between text-sm">
<span className="text-slate-600">
{item.name} × {item.quantity}
{item.fulfillment === "ship" && (
<span className="ml-1 text-xs text-blue-500">(ship)</span>
)}
</span>
<span className="font-medium text-slate-900">
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
</span>
</div>
))}
</div>
<div className="mt-4 border-t border-slate-200 pt-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-600">Subtotal</span>
<span className="text-slate-900">${subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-600">Estimated Tax</span>
<span className="text-slate-900 text-xs italic">Calculated at payment</span>
</div>
<div className="flex justify-between text-xl font-bold text-slate-900 pt-2 border-t border-slate-200">
<span>Total</span>
<span>${subtotal.toFixed(2)}</span>
</div>
</div>
{/* Trust badges */}
<div className="mt-4 space-y-1.5">
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Secured by Stripe</span>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span>100% Sweetness Guarantee</span>
</div>
</div>
</div>
</aside>
<CheckoutForm
state={state}
dispatch={dispatch}
cart={cart}
cartBrandId={cartBrandId}
selectedStop={selectedStop}
setSelectedStop={setSelectedStop}
stops={state.stops}
hasShedPickupItems={hasShedPickupItems}
hasStopPickupItems={hasStopPickupItems}
hasShipItems={hasShipItems}
expressItems={expressItems}
onHostedCheckout={() => void handleHostedCheckout()}
/>
<OrderSummary cart={cart} subtotal={subtotal} />
</div>
</main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
</div>
);
}
function EmptyCartView() {
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
<Link
href="/"
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
>
Back to storefront
</Link>
</div>
</main>
</div>
);
}
type CartItemLike = { id: string; name: string; price: string; quantity: number; fulfillment?: string; description?: string; pickup_type?: string };
type CheckoutFormProps = {
state: State;
dispatch: React.Dispatch<Action>;
cart: CartItemLike[];
cartBrandId: string | null | undefined;
selectedStop: StopInfo | null;
setSelectedStop: (stop: StopInfo | null) => void;
stops: StopInfo[];
hasShedPickupItems: boolean;
hasStopPickupItems: boolean;
hasShipItems: boolean;
expressItems: ExpressItem[];
onHostedCheckout: () => void;
};
function CheckoutForm({
state,
dispatch,
cart,
cartBrandId,
selectedStop,
setSelectedStop,
stops,
hasShedPickupItems,
hasStopPickupItems,
hasShipItems,
expressItems,
onHostedCheckout,
}: CheckoutFormProps) {
return (
<div>
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
<p className="mt-3 text-slate-600">
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure we never see them.
</p>
{/* Error alert (shared across both payment paths) */}
{state.hostedError && (
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{state.hostedError}</span>
</div>
</div>
)}
<div className="mt-8 space-y-6">
<CustomerDetailsFieldset state={state} dispatch={dispatch} />
{hasShedPickupItems && <ShedPickupNotice cart={cart} />}
{hasStopPickupItems && (
<StopPickupFieldset
selectedStop={selectedStop}
setSelectedStop={setSelectedStop}
stops={stops}
/>
)}
{hasShipItems && <ShippingAddressFieldset state={state} dispatch={dispatch} />}
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
<div data-testid="stripe-express-checkout">
<StripeExpressCheckout
items={expressItems}
brandId={cartBrandId ?? null}
customerName={state.customerName}
customerEmail={state.customerEmail}
customerPhone={state.customerPhone}
selectedStop={selectedStop as StopInfo | null}
shippingState={state.shippingState}
shippingPostal={state.shippingPostal}
shippingCity={state.shippingCity}
checkoutSessionKey={state.idempotencyKey || undefined}
onHostedCheckout={onHostedCheckout}
/>
</div>
<HostedCheckoutFallback
hostedLoading={state.hostedLoading}
onClick={onHostedCheckout}
/>
</div>
</div>
);
}
type CustomerDetailsFieldsetProps = {
state: State;
dispatch: React.Dispatch<Action>;
};
function CustomerDetailsFieldset({ state, dispatch }: CustomerDetailsFieldsetProps) {
return (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">Your details</legend>
<p className="mt-1 text-xs text-slate-500">
Used for the order confirmation. Apple Pay / Google Pay will fill these in for you.
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
Full Name
</label>
<input aria-label="Jane Smith"
id="customer_name"
name="customer_name"
autoComplete="name"
value={state.customerName}
onChange={(e) => dispatch({ type: "SET_NAME", value: e.target.value })}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="Jane Smith"
/>
</div>
<div>
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
Email <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input aria-label="Jane@example.com"
id="customer_email"
name="customer_email"
type="email"
autoComplete="email"
required
value={state.customerEmail}
onChange={(e) => dispatch({ type: "SET_EMAIL", value: e.target.value })}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="jane@example.com"
aria-required="true"
/>
</div>
<div>
<label htmlFor="customer_phone" className="block text-sm font-medium text-slate-700">
Phone <span className="text-slate-400 text-xs">(optional)</span>
</label>
<input aria-label="(555) 555 5555"
id="customer_phone"
name="customer_phone"
type="tel"
autoComplete="tel"
value={state.customerPhone}
onChange={(e) => dispatch({ type: "SET_PHONE", value: e.target.value })}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="(555) 555-5555"
/>
</div>
</div>
</fieldset>
);
}
function ShedPickupNotice({ cart }: { cart: CartItemLike[] }) {
return (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
<p className="mt-2 text-sm text-slate-600">
{cart.flatMap((i) =>
i.fulfillment === "pickup" && i.pickup_type === "shed"
? [i.description || i.name]
: []
).join(", ")}
</p>
<p className="mt-1 text-xs text-slate-500">Pickup location details are included in your order confirmation.</p>
</fieldset>
);
}
type StopPickupFieldsetProps = {
selectedStop: StopInfo | null;
setSelectedStop: (stop: StopInfo | null) => void;
stops: StopInfo[];
};
function StopPickupFieldset({ selectedStop, setSelectedStop, stops }: StopPickupFieldsetProps) {
return (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">
Pickup Location <span className="text-red-500" aria-hidden="true">*</span>
</legend>
{selectedStop ? (
<SelectedStopCard
selectedStop={selectedStop}
onChange={() => setSelectedStop(null)}
/>
) : (
<StopSelector stops={stops} setSelectedStop={setSelectedStop} />
)}
</fieldset>
);
}
function SelectedStopCard({ selectedStop, onChange }: { selectedStop: StopInfo; onChange: () => void }) {
return (
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<p className="font-medium text-emerald-900">
<svg className="inline h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
{selectedStop.city}, {selectedStop.state}
</p>
<p className="mt-1 text-sm text-emerald-700">
{selectedStop.date} · {selectedStop.location}
</p>
<button
type="button"
onClick={onChange}
className="mt-2 text-xs text-emerald-600 hover:text-emerald-800 underline transition-colors"
aria-label="Change pickup stop"
>
Change pickup stop
</button>
</div>
);
}
function StopSelector({ stops, setSelectedStop }: { stops: StopInfo[]; setSelectedStop: (stop: StopInfo | null) => void }) {
return (
<div className="mt-4">
<label htmlFor="stop_id" className="block text-sm font-medium text-slate-700 mb-2">
Select pickup stop <span className="text-red-500" aria-hidden="true">*</span>
</label>
<select aria-label="Stop Id"
id="stop_id"
name="stop_id"
required
value={""}
onChange={(e) => {
const found = stops.find((s) => s.id === e.target.value) ?? null;
setSelectedStop(found as StopInfo | null);
}}
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
aria-required="true"
>
<option value="">Select a stop...</option>
{stops.map((stop) => (
<option key={stop.id} value={stop.id}>
{stop.city}, {stop.state} {stop.date} @ {stop.location}
</option>
))}
</select>
</div>
);
}
function ShippingAddressFieldset({ state, dispatch }: { state: State; dispatch: React.Dispatch<Action> }) {
return (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">Shipping Address</legend>
<p className="mt-1 text-sm text-slate-500">
Enter your shipping details for delivery.
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-3">
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
Street Address
</label>
<input aria-label="123 Main St"
id="shipping_address"
name="shipping_address"
autoComplete="street-address"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="123 Main St"
/>
</div>
<div>
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
City
</label>
<input aria-label="Shipping City"
id="shipping_city"
name="shipping_city"
autoComplete="address-level2"
value={state.shippingCity}
onChange={(e) => dispatch({ type: "SET_SHIPPING_CITY", value: e.target.value })}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
</div>
<div>
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
State
</label>
<input aria-label="NC"
id="shipping_state"
name="shipping_state"
autoComplete="address-level1"
maxLength={2}
value={state.shippingState}
onChange={(e) => dispatch({ type: "SET_SHIPPING_STATE", value: e.target.value.toUpperCase() })}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
placeholder="NC"
/>
</div>
<div>
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
ZIP Code
</label>
<input aria-label="28147"
id="shipping_postal_code"
name="shipping_postal_code"
autoComplete="postal-code"
value={state.shippingPostal}
onChange={(e) => dispatch({ type: "SET_SHIPPING_POSTAL", value: e.target.value })}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="28147"
/>
</div>
</div>
</fieldset>
);
}
function HostedCheckoutFallback({ hostedLoading, onClick }: { hostedLoading: boolean; onClick: () => void }) {
return (
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
<p className="text-xs text-slate-500 mb-2 text-center">
Prefer Stripe&apos;s hosted page? Tap below.
</p>
<button
type="button"
onClick={onClick}
disabled={hostedLoading}
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
>
{hostedLoading ? (
<>
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
Redirecting to Stripe
</>
) : (
<>Use secure hosted checkout </>
)}
</button>
</div>
);
}
function OrderSummary({ cart, subtotal }: { cart: CartItemLike[]; subtotal: number }) {
return (
<aside aria-label="Order summary">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
<div className="mt-4 space-y-3">
{cart.map((item) => (
<div key={item.id} className="flex justify-between text-sm">
<span className="text-slate-600">
{item.name} × {item.quantity}
{item.fulfillment === "ship" && (
<span className="ml-1 text-xs text-blue-500">(ship)</span>
)}
</span>
<span className="font-medium text-slate-900">
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
</span>
</div>
))}
</div>
<div className="mt-4 border-t border-slate-200 pt-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-600">Subtotal</span>
<span className="text-slate-900">${subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-600">Estimated Tax</span>
<span className="text-slate-900 text-xs italic">Calculated at payment</span>
</div>
<div className="flex justify-between text-xl font-bold text-slate-900 pt-2 border-t border-slate-200">
<span>Total</span>
<span>${subtotal.toFixed(2)}</span>
</div>
</div>
{/* Trust badges */}
<div className="mt-4 space-y-1.5">
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Secured by Stripe</span>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span>100% Sweetness Guarantee</span>
</div>
</div>
</div>
</aside>
);
}
+84 -38
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, Suspense, lazy } from "react";
import { useEffect, useReducer, Suspense, lazy } from "react";
import Image from "next/image";
import { m as motion } from "framer-motion";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
@@ -15,30 +15,76 @@ const ValuesSection = lazy(() => import("@/components/about/IndianRiverValuesSec
const ContactSection = lazy(() => import("@/components/about/IndianRiverContactSection"));
const CTASection = lazy(() => import("@/components/about/IndianRiverCTASection"));
type State = {
logoUrl: string | null;
logoUrlDark: string | null;
showWholesaleLink: boolean;
isAdmin: boolean;
customFooterText: string | null;
contactEmail: string | null;
contactPhone: string | null;
};
type Action =
| {
type: "HYDRATE_BRAND";
logoUrl: string | null;
logoUrlDark: string | null;
showWholesaleLink: boolean;
customFooterText: string | null;
contactEmail: string | null;
contactPhone: string | null;
}
| { type: "SET_ADMIN"; value: boolean };
const initialState: State = {
logoUrl: null,
logoUrlDark: null,
showWholesaleLink: true,
isAdmin: false,
customFooterText: null,
contactEmail: null,
contactPhone: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "HYDRATE_BRAND":
return {
...state,
logoUrl: action.logoUrl,
logoUrlDark: action.logoUrlDark,
showWholesaleLink: action.showWholesaleLink,
customFooterText: action.customFooterText,
contactEmail: action.contactEmail,
contactPhone: action.contactPhone,
};
case "SET_ADMIN":
return { ...state, isAdmin: action.value };
}
}
export default function IndianRiverAboutPage() {
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
const [showWholesaleLink, setShowWholesaleLink] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [customFooterText, setCustomFooterText] = useState<string | null>(null);
const [contactEmail, setContactEmail] = useState<string | null>(null);
const [contactPhone, setContactPhone] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
async function load() {
const settingsResult = await getBrandSettingsPublic("indian-river-direct");
if (settingsResult.success && settingsResult.settings) {
const s = settingsResult.settings;
setLogoUrl(s.logo_url ?? null);
setLogoUrlDark(s.logo_url_dark ?? null);
setCustomFooterText(s.custom_footer_text ?? null);
setContactEmail(s.email ?? null);
setContactPhone(s.phone ?? null);
setShowWholesaleLink(s.show_wholesale_link ?? true);
dispatch({
type: "HYDRATE_BRAND",
logoUrl: s.logo_url ?? null,
logoUrlDark: s.logo_url_dark ?? null,
showWholesaleLink: s.show_wholesale_link ?? true,
customFooterText: s.custom_footer_text ?? null,
contactEmail: s.email ?? null,
contactPhone: s.phone ?? null,
});
}
try {
const { getCurrentAdminUser } = await import("@/actions/admin-user");
setIsAdmin(!!await getCurrentAdminUser());
try {
const { getCurrentAdminUser } = await import("@/actions/admin-user");
dispatch({ type: "SET_ADMIN", value: !!(await getCurrentAdminUser()) });
} catch { /* not logged in */ }
}
load();
@@ -46,14 +92,14 @@ export default function IndianRiverAboutPage() {
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader
brandName="Indian River Direct"
brandSlug="indian-river-direct"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
showWholesaleLink={showWholesaleLink}
isAdmin={isAdmin}
brandAccent="blue"
<StorefrontHeader
brandName="Indian River Direct"
brandSlug="indian-river-direct"
logoUrl={state.logoUrl}
logoUrlDark={state.logoUrlDark}
showWholesaleLink={state.showWholesaleLink}
isAdmin={state.isAdmin}
brandAccent="blue"
/>
<main>
@@ -113,9 +159,9 @@ export default function IndianRiverAboutPage() {
>
<div className="relative">
<div className="absolute -inset-6 bg-blue-900/30 rounded-full blur-2xl" />
{logoUrlDark ? (
{state.logoUrlDark ? (
<Image
src={logoUrlDark}
src={state.logoUrlDark}
alt="Indian River Direct"
width={288}
height={288}
@@ -158,7 +204,7 @@ export default function IndianRiverAboutPage() {
{/* Contact Section */}
<Suspense fallback={<LoadingFallback bg="bg-stone-950" />}>
<ContactSection phone={contactPhone} email={contactEmail} />
<ContactSection phone={state.contactPhone} email={state.contactEmail} />
</Suspense>
{/* CTA Section */}
@@ -167,16 +213,16 @@ export default function IndianRiverAboutPage() {
</Suspense>
</main>
<StorefrontFooter
brandName="Indian River Direct"
brandSlug="indian-river-direct"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
customFooterText={customFooterText}
contactEmail={contactEmail}
contactPhone={contactPhone}
isAdmin={isAdmin}
brandAccent="blue"
<StorefrontFooter
brandName="Indian River Direct"
brandSlug="indian-river-direct"
logoUrl={state.logoUrl}
logoUrlDark={state.logoUrlDark}
customFooterText={state.customFooterText}
contactEmail={state.contactEmail}
contactPhone={state.contactPhone}
isAdmin={state.isAdmin}
brandAccent="blue"
/>
</div>
);
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useReducer } from "react";
import { m as motion } from "framer-motion";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
@@ -22,43 +22,111 @@ type FormState = {
message: string;
};
type State = {
brandSettings: BrandSettings | null;
submitted: boolean;
form: FormState;
isSubmitting: boolean;
logoUrl: string | null;
isAdmin: boolean;
customFooterText: string | null;
contactEmail: string | null;
contactPhone: string | null;
};
type Action =
| { type: "SET_BRAND_SETTINGS"; value: BrandSettings | null }
| { type: "SET_LOGO_URL"; value: string | null }
| { type: "SET_ADMIN"; value: boolean }
| { type: "SET_CUSTOM_FOOTER"; value: string | null }
| { type: "SET_CONTACT_EMAIL"; value: string | null }
| { type: "SET_CONTACT_PHONE"; value: string | null }
| { type: "SET_FORM_NAME"; value: string }
| { type: "SET_FORM_EMAIL"; value: string }
| { type: "SET_FORM_TOPIC"; value: string }
| { type: "SET_FORM_MESSAGE"; value: string }
| { type: "RESET_FORM" }
| { type: "START_SUBMIT" }
| { type: "SUBMIT_COMPLETE" };
const initialForm: FormState = { name: "", email: "", topic: "general", message: "" };
const initialState: State = {
brandSettings: null,
submitted: false,
form: initialForm,
isSubmitting: false,
logoUrl: null,
isAdmin: false,
customFooterText: null,
contactEmail: null,
contactPhone: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_BRAND_SETTINGS":
return { ...state, brandSettings: action.value };
case "SET_LOGO_URL":
return { ...state, logoUrl: action.value };
case "SET_ADMIN":
return { ...state, isAdmin: action.value };
case "SET_CUSTOM_FOOTER":
return { ...state, customFooterText: action.value };
case "SET_CONTACT_EMAIL":
return { ...state, contactEmail: action.value };
case "SET_CONTACT_PHONE":
return { ...state, contactPhone: action.value };
case "SET_FORM_NAME":
return { ...state, form: { ...state.form, name: action.value } };
case "SET_FORM_EMAIL":
return { ...state, form: { ...state.form, email: action.value } };
case "SET_FORM_TOPIC":
return { ...state, form: { ...state.form, topic: action.value } };
case "SET_FORM_MESSAGE":
return { ...state, form: { ...state.form, message: action.value } };
case "RESET_FORM":
return { ...state, submitted: false, form: initialForm };
case "START_SUBMIT":
return { ...state, isSubmitting: true };
case "SUBMIT_COMPLETE":
return { ...state, isSubmitting: false, submitted: true };
}
}
export default function IndianRiverContactPage() {
const [brandSettings, setBrandSettings] = useState<BrandSettings | null>(null);
const [submitted, setSubmitted] = useState(false);
const [form, setForm] = useState<FormState>({ name: "", email: "", topic: "general", message: "" });
const [isSubmitting, setIsSubmitting] = useState(false);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [customFooterText, setCustomFooterText] = useState<string | null>(null);
const [contactEmail, setContactEmail] = useState<string | null>(null);
const [contactPhone, setContactPhone] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
supabase.from("brands").select("id").eq("slug", "indian-river-direct").single().then(({ data: brand }) => {
if (!brand?.id) return;
supabase.from("wholesale_settings")
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
.eq("brand_id", brand.id).single().then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
.eq("brand_id", brand.id).single().then(({ data }) => dispatch({ type: "SET_BRAND_SETTINGS", value: (data as unknown as BrandSettings) ?? null }));
supabase.from("brand_settings").select("logo_url, custom_footer_text, email, phone")
.eq("brand_id", brand.id).single().then(({ data: s }) => {
if (s) { setLogoUrl((s as { logo_url: string | null }).logo_url ?? null); setCustomFooterText((s as { custom_footer_text: string | null }).custom_footer_text ?? null); setContactEmail((s as { email: string | null }).email ?? null); setContactPhone((s as { phone: string | null }).phone ?? null); }
if (s) {
dispatch({ type: "SET_LOGO_URL", value: (s as { logo_url: string | null }).logo_url ?? null });
dispatch({ type: "SET_CUSTOM_FOOTER", value: (s as { custom_footer_text: string | null }).custom_footer_text ?? null });
dispatch({ type: "SET_CONTACT_EMAIL", value: (s as { email: string | null }).email ?? null });
dispatch({ type: "SET_CONTACT_PHONE", value: (s as { phone: string | null }).phone ?? null });
}
});
try { import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u)); }); } catch { /* not logged in */ }
try { import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { getCurrentAdminUser().then((u: unknown) => dispatch({ type: "SET_ADMIN", value: !!u })); }); } catch { /* not logged in */ }
});
}, []);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
dispatch({ type: "START_SUBMIT" });
setTimeout(() => {
setSubmitted(true);
setIsSubmitting(false);
dispatch({ type: "SUBMIT_COMPLETE" });
}, 600);
}
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Indian River Direct" brandSlug="indian-river-direct" logoUrl={logoUrl} showWholesaleLink={true} isAdmin={isAdmin} brandAccent="blue" />
<StorefrontHeader brandName="Indian River Direct" brandSlug="indian-river-direct" logoUrl={state.logoUrl} showWholesaleLink={true} isAdmin={state.isAdmin} brandAccent="blue" />
<main className="px-6 py-16 md:py-20">
<LayoutContainer>
@@ -84,19 +152,19 @@ export default function IndianRiverContactPage() {
{[
{
label: "Address",
value: brandSettings?.invoice_business_address ?? "Indian River Region, Florida",
value: state.brandSettings?.invoice_business_address ?? "Indian River Region, Florida",
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />,
iconPath: <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
},
{
label: "Phone",
value: contactPhone ?? "772-971-4484",
value: state.contactPhone ?? "772-971-4484",
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.184-.046-.379-.041-.545.114L18 10.48a2.25 2.25 0 00-.545-.114l-4.423 1.106c-.5.119-.852.575-.852 1.091v1.372a2.25 2.25 0 01-2.25 2.25h-2.25a15 15 0 01-15-15z" />,
iconPath: null
},
{
label: "Email",
value: brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com",
value: state.brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com",
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />,
iconPath: null
},
@@ -136,7 +204,7 @@ export default function IndianRiverContactPage() {
</h2>
<div className="mt-4 h-px w-12 bg-blue-600" />
</div>
{submitted ? (
{state.submitted ? (
<div className="rounded-2xl bg-blue-50 border-2 border-blue-100 p-10 text-center shadow-md">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 mb-4 shadow-md">
<svg className="h-7 w-7 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -146,10 +214,7 @@ export default function IndianRiverContactPage() {
<p className="text-xl font-bold text-stone-950">Message received.</p>
<p className="mt-2 text-base text-stone-500">We will be in touch within 1-2 business days.</p>
<button type="button"
onClick={() => {
setSubmitted(false);
setForm({ name: "", email: "", topic: "general", message: "" });
}}
onClick={() => dispatch({ type: "RESET_FORM" })}
className="mt-5 text-sm font-semibold text-blue-700 underline hover:text-blue-900"
>
Send another message
@@ -162,8 +227,8 @@ export default function IndianRiverContactPage() {
<label htmlFor="fld-1-your-name" className="block text-sm font-semibold text-stone-700 mb-2">Your Name</label>
<input id="fld-1-your-name" aria-label="Jane Smith"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
value={state.form.name}
onChange={(e) => dispatch({ type: "SET_FORM_NAME", value: e.target.value })}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 placeholder:text-stone-400 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors"
placeholder="Jane Smith"
autoComplete="name"
@@ -174,8 +239,8 @@ export default function IndianRiverContactPage() {
<input id="fld-2-email" aria-label="Jane@example.com"
required
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
value={state.form.email}
onChange={(e) => dispatch({ type: "SET_FORM_EMAIL", value: e.target.value })}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 placeholder:text-stone-400 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors"
placeholder="jane@example.com"
autoComplete="email"
@@ -185,8 +250,8 @@ export default function IndianRiverContactPage() {
<div>
<label htmlFor="fld-3-topic" className="block text-sm font-semibold text-stone-700 mb-2">Topic</label>
<select id="fld-3-topic" aria-label="Select"
value={form.topic}
onChange={(e) => setForm({ ...form, topic: e.target.value })}
value={state.form.topic}
onChange={(e) => dispatch({ type: "SET_FORM_TOPIC", value: e.target.value })}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors appearance-none"
>
<option value="general">General Question</option>
@@ -200,18 +265,18 @@ export default function IndianRiverContactPage() {
<textarea id="fld-4-message" aria-label="How Can We Help You?"
required
rows={5}
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
value={state.form.message}
onChange={(e) => dispatch({ type: "SET_FORM_MESSAGE", value: e.target.value })}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 placeholder:text-stone-400 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors resize-none"
placeholder="How can we help you?"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
disabled={state.isSubmitting}
className="rounded-xl bg-blue-600 hover:bg-blue-500 px-8 py-4 text-base font-bold text-white hover:shadow-lg hover:shadow-blue-600/25 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSubmitting ? "Sending..." : "Send Message"}
{state.isSubmitting ? "Sending..." : "Send Message"}
</button>
</form>
)}
@@ -269,7 +334,7 @@ export default function IndianRiverContactPage() {
</LayoutContainer>
</main>
<StorefrontFooter brandName="Indian River Direct" brandSlug="indian-river-direct" logoUrl={logoUrl} customFooterText={customFooterText} contactEmail={contactEmail} contactPhone={contactPhone} isAdmin={isAdmin} brandAccent="blue" />
<StorefrontFooter brandName="Indian River Direct" brandSlug="indian-river-direct" logoUrl={state.logoUrl} customFooterText={state.customFooterText} contactEmail={state.contactEmail} contactPhone={state.contactPhone} isAdmin={state.isAdmin} brandAccent="blue" />
</div>
);
}
+510 -410
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useEffect, useReducer, useRef } from "react";
import Link from "next/link";
import Image from "next/image";
import { m as motion, useInView } from "framer-motion";
@@ -13,15 +13,15 @@ import { getBrandSettingsPublic } from "@/actions/brand-settings";
type Brand = { id: string; name: string; slug: string };
type Stop = { id: string; city: string; state: string; date: string; time: string; location: string; slug: string; brand_id: string };
type Product = {
id: string;
name: string;
description: string | null;
price: number;
type: string;
image_url: string | null;
brand_id: string;
is_taxable?: boolean;
type Product = {
id: string;
name: string;
description: string | null;
price: number;
type: string;
image_url: string | null;
brand_id: string;
is_taxable?: boolean;
pickup_type?: "scheduled_stop" | "shed";
seasonal?: boolean;
season_start?: string;
@@ -31,6 +31,37 @@ type Product = {
pickup_only?: boolean;
};
type State = {
brand: Brand | null;
stops: Stop[];
products: Product[];
logoUrl: string | null;
logoUrlDark: string | null;
showSchedulePdf: boolean;
showWholesaleLink: boolean;
heroTagline: string | null;
isAdmin: boolean;
customFooterText: string | null;
contactEmail: string | null;
contactPhone: string | null;
};
type Action =
| { type: "SET_BRAND"; brand: Brand | null }
| { type: "SET_STOPS"; stops: Stop[] }
| { type: "SET_PRODUCTS"; products: Product[] }
| { type: "SET_BRAND_SETTINGS"; settings: {
logoUrl: string | null;
logoUrlDark: string | null;
heroTagline: string | null;
customFooterText: string | null;
contactEmail: string | null;
contactPhone: string | null;
showSchedulePdf: boolean;
showWholesaleLink: boolean;
} }
| { type: "SET_IS_ADMIN"; value: boolean };
const TESTIMONIALS = [
{ name: "Linda Hurlbut", text: "I finally got my grapefruit from you today. OMG thank you for bringing your fruit to us. It is amazing. We will see you next month & tell our friends.", rating: 5 },
{ name: "Phil Myers", text: "I just wanted to comment on the citrus I received. They were absolutely the best I have got from you over the past few years!", rating: 5 },
@@ -42,14 +73,54 @@ const SCHEDULE_PDFS = [
{ region: "WI-IL", url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/2026_WI-IL_Schedule_Draft_Website.pdf" },
];
const initialState: State = {
brand: null,
stops: [],
products: [],
logoUrl: null,
logoUrlDark: null,
showSchedulePdf: true,
showWholesaleLink: true,
heroTagline: null,
isAdmin: false,
customFooterText: null,
contactEmail: null,
contactPhone: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_BRAND":
return { ...state, brand: action.brand };
case "SET_STOPS":
return { ...state, stops: action.stops };
case "SET_PRODUCTS":
return { ...state, products: action.products };
case "SET_BRAND_SETTINGS":
return {
...state,
logoUrl: action.settings.logoUrl,
logoUrlDark: action.settings.logoUrlDark,
heroTagline: action.settings.heroTagline,
customFooterText: action.settings.customFooterText,
contactEmail: action.settings.contactEmail,
contactPhone: action.settings.contactPhone,
showSchedulePdf: action.settings.showSchedulePdf,
showWholesaleLink: action.settings.showWholesaleLink,
};
case "SET_IS_ADMIN":
return { ...state, isAdmin: action.value };
}
}
// Scroll-triggered fade-in wrapper
function FadeInSection({
children,
className = "",
delay = 0
}: {
children: React.ReactNode;
className?: string;
function FadeInSection({
children,
className = "",
delay = 0,
}: {
children: React.ReactNode;
className?: string;
delay?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
@@ -69,18 +140,7 @@ function FadeInSection({
}
export default function IndianRiverDirectPage() {
const [brand, setBrand] = useState<Brand | null>(null);
const [stops, setStops] = useState<Stop[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
const [showSchedulePdf, setShowSchedulePdf] = useState(true);
const [showWholesaleLink, setShowWholesaleLink] = useState(true);
const [heroTagline, setHeroTagline] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [customFooterText, setCustomFooterText] = useState<string | null>(null);
const [contactEmail, setContactEmail] = useState<string | null>(null);
const [contactPhone, setContactPhone] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
async function load() {
@@ -91,22 +151,27 @@ export default function IndianRiverDirectPage() {
]);
const brandData = brandResult.data as Brand | null;
setBrand(brandData);
dispatch({ type: "SET_BRAND", brand: brandData });
if (settingsResult.success && settingsResult.settings) {
const s = settingsResult.settings;
setLogoUrl(s.logo_url ?? null);
setLogoUrlDark(s.logo_url_dark ?? null);
setHeroTagline(s.hero_tagline ?? null);
setCustomFooterText(s.custom_footer_text ?? null);
setContactEmail(s.email ?? null);
setContactPhone(s.phone ?? null);
setShowSchedulePdf(s.show_schedule_pdf ?? true);
setShowWholesaleLink(s.show_wholesale_link ?? true);
dispatch({
type: "SET_BRAND_SETTINGS",
settings: {
logoUrl: s.logo_url ?? null,
logoUrlDark: s.logo_url_dark ?? null,
heroTagline: s.hero_tagline ?? null,
customFooterText: s.custom_footer_text ?? null,
contactEmail: s.email ?? null,
contactPhone: s.phone ?? null,
showSchedulePdf: s.show_schedule_pdf ?? true,
showWholesaleLink: s.show_wholesale_link ?? true,
},
});
}
try {
const { getCurrentAdminUser } = await import("@/actions/admin-user");
setIsAdmin(!!await getCurrentAdminUser());
dispatch({ type: "SET_IS_ADMIN", value: !!(await getCurrentAdminUser()) });
} catch { /* not logged in */ }
if (brandData?.id) {
@@ -114,8 +179,8 @@ export default function IndianRiverDirectPage() {
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
]);
setStops(stopsData ?? []);
setProducts(productsData ?? []);
dispatch({ type: "SET_STOPS", stops: stopsData ?? [] });
dispatch({ type: "SET_PRODUCTS", products: productsData ?? [] });
}
}
load();
@@ -124,387 +189,422 @@ export default function IndianRiverDirectPage() {
return (
<div className="min-h-screen bg-white/50 backdrop-blur-xl">
<StorefrontHeader
brandName={brand?.name ?? "Indian River Direct"}
brandName={state.brand?.name ?? "Indian River Direct"}
brandSlug="indian-river-direct"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
showWholesaleLink={showWholesaleLink}
isAdmin={isAdmin}
logoUrl={state.logoUrl}
logoUrlDark={state.logoUrlDark}
showWholesaleLink={state.showWholesaleLink}
isAdmin={state.isAdmin}
brandAccent="blue"
/>
<main>
{/* ── Hero — Light Fresh Background ── */}
<section className="relative bg-gradient-to-b from-blue-50 via-white to-stone-50 overflow-hidden min-h-[640px] flex items-center">
{/* Subtle decorative elements */}
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-100/40 rounded-full -translate-y-1/2 translate-x-1/4 blur-3xl" />
<div className="absolute bottom-0 left-0 w-80 h-80 bg-blue-50/60 rounded-full translate-y-1/2 -translate-x-1/4 blur-3xl" />
<LayoutContainer>
<div className="relative z-10 mx-auto max-w-4xl text-center pt-20 pb-16">
{/* Eyebrow */}
<motion.p
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-xs font-bold uppercase tracking-[0.3em] text-blue-600 mb-5"
>
Since 1985
</motion.p>
{/* Headline */}
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="text-5xl md:text-6xl lg:text-7xl font-black tracking-tight text-stone-950 leading-[1.05]"
>
Fresh From<br />The Grove
</motion.h1>
{/* Subheading */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.35 }}
className="mt-8 text-xl md:text-2xl text-stone-600 leading-relaxed max-w-2xl mx-auto"
>
{heroTagline ?? "Family-owned and operated. Bringing the finest peaches, pecans, and seasonal citrus from our Florida groves directly to your neighborhood."}
</motion.p>
{/* CTA Buttons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.5 }}
className="mt-12 flex gap-4 justify-center flex-wrap"
>
<Link
href="/indian-river-direct#stops"
className="rounded-2xl bg-blue-600 px-8 py-4 font-bold text-white hover:bg-blue-500 active:bg-blue-700 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-blue-900/20 transition-all text-sm tracking-wider"
>
Find a Stop
</Link>
<Link
href="/indian-river-direct#products"
className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all text-sm tracking-wider border border-blue-200"
>
Shop Products
</Link>
</motion.div>
</div>
</LayoutContainer>
{/* Scroll indicator */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.2, duration: 0.6 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2"
>
<motion.div
animate={{ y: [0, 8, 0] }}
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
className="flex flex-col items-center gap-2"
>
<span className="text-[10px] uppercase tracking-widest text-stone-400">Scroll</span>
<svg className="w-5 h-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</motion.div>
</motion.div>
</section>
{/* ── Why Indian River Direct Section ── */}
<HeroSection heroTagline={state.heroTagline} />
<WhyIndianRiverDirect />
{/* ── Pickup-Only Notice ── */}
<FadeInSection className="py-16 bg-white relative">
<LayoutContainer>
<div className="mx-auto max-w-2xl">
<div className="rounded-3xl bg-gradient-to-br from-blue-50/80 to-stone-50/80 backdrop-blur-sm border border-blue-100/50 p-8 shadow-xl shadow-blue-200/30">
<div className="flex items-center justify-center gap-5">
<div className="flex-shrink-0">
<div className="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-600/30">
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
<div>
<h3 className="text-lg font-bold text-stone-950">Pickup Only Direct from the Truck</h3>
<p className="text-stone-600 text-sm mt-1">All products picked up at our scheduled truck stops across Ohio, Indiana, Kentucky, West Virginia, Wisconsin, and Illinois.</p>
</div>
</div>
</div>
</div>
</LayoutContainer>
</FadeInSection>
{/* ── Schedule Downloads ── */}
{showSchedulePdf && (
<FadeInSection className="py-20 bg-gradient-to-b from-blue-50/50 to-stone-50/50 relative">
<LayoutContainer>
<div className="mb-12">
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">2026 Season</p>
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">Pickup Schedule</h2>
<div className="mt-4 h-px w-12 bg-blue-600" />
<p className="mt-4 text-stone-600">Download our schedule to find stops in your area.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 max-w-2xl">
{SCHEDULE_PDFS.map((pdf) => (
<a key={pdf.region}
href={pdf.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-2xl bg-white border-2 border-stone-200 p-6 flex items-center gap-4 group shadow-lg hover:shadow-xl hover:border-blue-200 hover:-translate-y-1 transition-all duration-300"
>
<div className="flex-shrink-0">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center shadow-lg shadow-blue-600/30">
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div className="flex-1">
<h3 className="font-bold text-stone-950 text-lg">{pdf.region}</h3>
<p className="text-stone-500 text-sm">2026 Season Schedule</p>
</div>
<svg className="h-5 w-5 text-blue-500 group-hover:text-blue-700 group-hover:translate-x-1 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
))}
</div>
</LayoutContainer>
</FadeInSection>
)}
{/* ── Upcoming Stops ── */}
<FadeInSection className="py-20 bg-white relative">
<LayoutContainer>
<div className="mb-12">
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Family Farms</p>
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">Upcoming Stops</h2>
<div className="mt-4 h-px w-12 bg-blue-600" />
<p className="mt-4 text-stone-600">Find a pickup location near you.</p>
</div>
{stops.length === 0 ? (
<div className="rounded-3xl bg-stone-50 border-2 border-stone-200 p-12 text-center">
<p className="text-stone-500">No upcoming stops scheduled. Check back soon for the 2026 season!</p>
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{stops.slice(0, 6).map((stop) => (
<div key={stop.id} className="rounded-2xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-5 shadow-lg hover:shadow-xl hover:border-blue-200 hover:-translate-y-1 transition-all duration-300">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-bold text-stone-950 text-lg">{stop.city}, {stop.state}</h3>
<p className="text-stone-500 text-sm">{stop.location}</p>
</div>
<span className="shrink-0 rounded-full bg-blue-600 px-3 py-1 text-xs font-bold text-white shadow-sm">
Pickup
</span>
</div>
<div className="text-sm text-stone-600">
<p className="font-semibold">{stop.date}</p>
<p>{stop.time}</p>
</div>
</div>
))}
</div>
{stops.length > 6 && (
<div className="mt-8 text-center">
<Link
href="/indian-river-direct/stops"
className="inline-flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
View all stops
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
)}
</>
)}
</LayoutContainer>
</FadeInSection>
{/* ── Products Section ── */}
<FadeInSection className="py-20 bg-stone-100 relative">
<LayoutContainer>
<div className="mb-12">
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Fresh Products</p>
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">From Our Grove to You</h2>
<div className="mt-4 h-px w-12 bg-blue-600" />
<p className="mt-4 max-w-2xl text-lg text-stone-600 leading-relaxed">
Hand-picked at peak ripeness, delivered fresh from Titan Farms (South Carolina peaches) and Ellis Brothers (Georgia pecans) directly to your community.
</p>
</div>
{products.length === 0 ? (
<div className="rounded-3xl bg-white border-2 border-stone-200 p-12 text-center shadow-lg">
<p className="text-stone-500">Products coming soon for the 2026 season!</p>
</div>
) : (
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<div key={product.id} className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden shadow-xl hover:shadow-2xl hover:-translate-y-2 hover:border-blue-200 transition-all duration-300 group">
{/* Product Image */}
<div className="aspect-[4/3] bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center relative overflow-hidden">
{product.image_url ? (
<Image src={product.image_url} alt={product.name} fill className="object-cover transition-transform duration-500 group-hover:scale-105" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" />
) : (
<div className="text-6xl opacity-30 transition-transform duration-500 group-hover:scale-110">🍑</div>
)}
{/* Badges */}
<div className="absolute top-3 left-3 flex flex-wrap gap-2">
{product.preorder && (
<span className="rounded-full bg-white text-blue-700 px-3 py-1 text-xs font-bold shadow-lg">
Pre-Order
</span>
)}
{product.seasonal && (
<span className="rounded-full bg-stone-900/80 backdrop-blur-sm px-3 py-1 text-xs font-medium text-white">
{product.season_start} - {product.season_end}
</span>
)}
{product.pickup_only && (
<span className="rounded-full bg-blue-600 text-white px-3 py-1 text-xs font-medium shadow-md">
Pickup Only
</span>
)}
</div>
</div>
{/* Product Info */}
<div className="p-6">
<h3 className="text-lg font-bold text-stone-950 mb-2">{product.name}</h3>
<p className="text-stone-600 text-sm leading-relaxed line-clamp-2 mb-4">
{product.description}
</p>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-stone-950">
{product.price_tba ? "Price TBA" : `$${product.price.toFixed(2)}`}
</span>
{product.price > 0 && (
<button type="button" className="rounded-xl bg-blue-600 hover:bg-blue-500 active:bg-blue-700 px-5 py-2.5 text-sm font-bold text-white transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0">
{product.preorder ? "Pre-Order" : "Add to Cart"}
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</LayoutContainer>
</FadeInSection>
{/* ── Testimonials ── */}
<FadeInSection className="py-20 bg-gradient-to-b from-blue-50 to-blue-100/50 relative">
<LayoutContainer>
<div className="text-center mb-12">
<div className="flex items-center justify-center gap-3 mb-3">
<span className="h-px w-12 bg-gradient-to-r from-transparent to-blue-600" />
<div className="flex">
{[...Array(5)].map((_, i) => (
<svg key={i} className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
<span className="h-px w-12 bg-gradient-to-l from-transparent to-blue-600" />
</div>
<h2 className="text-3xl md:text-4xl font-black text-stone-950 tracking-tight">What Our Customers Say</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{TESTIMONIALS.map((t) => (
<div key={t.name} className="rounded-2xl bg-white border-2 border-stone-200 p-6 shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all duration-300">
<div className="flex mb-4">
{[...Array(t.rating)].map((_, j) => (
<svg key={j} className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
<blockquote className="text-stone-600 text-sm leading-relaxed mb-4">
&quot;{t.text}&quot;
</blockquote>
<p className="text-stone-950 font-bold text-sm"> {t.name}</p>
</div>
))}
</div>
</LayoutContainer>
</FadeInSection>
{/* ── Newsletter ── */}
<FadeInSection className="py-20 bg-stone-100 relative">
<LayoutContainer>
<div className="mx-auto max-w-2xl text-center">
<div className="rounded-3xl bg-gradient-to-br from-blue-600 to-blue-700 p-10 shadow-2xl shadow-blue-900/30 relative overflow-hidden">
{/* Decorative pattern */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
</div>
<div className="relative">
<h2 className="text-2xl md:text-3xl font-black text-white">Stay in the Loop</h2>
<p className="mt-2 text-blue-100">Get the annual schedules and tour updates delivered to your inbox.</p>
<form className="flex gap-3 max-w-md mx-auto mt-6">
<input aria-label="Your@email.com"
type="email"
placeholder="your@email.com"
className="flex-1 rounded-xl border-2 border-white/30 bg-white/90 backdrop-blur-sm px-4 py-3 text-stone-900 placeholder:text-stone-400 outline-none focus:border-white focus:ring-2 focus:ring-white/50 transition-all"
/>
<button type="button" className="rounded-xl bg-white text-blue-700 hover:bg-blue-50 px-6 py-3 font-bold transition-all shadow-lg hover:shadow-xl whitespace-nowrap">
Subscribe
</button>
</form>
</div>
</div>
</div>
</LayoutContainer>
</FadeInSection>
{/* ── CTA ── */}
<section className="py-20 bg-gradient-to-br from-blue-600 via-blue-700 to-blue-800 relative overflow-hidden">
{/* Decorative glow */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.1),transparent_50%)]" />
<LayoutContainer>
<div className="relative text-center">
<h2 className="text-2xl md:text-3xl font-black text-white">Ready to order?</h2>
<p className="mt-2 text-blue-100">Find a stop near you or browse our seasonal products.</p>
<div className="mt-8 flex gap-4 justify-center flex-wrap">
<Link href="/indian-river-direct#stops" className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
Find a Stop
</Link>
<Link href="/indian-river-direct/about" className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 px-8 py-4 font-bold text-white hover:bg-blue-600/80 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
Our Story
</Link>
</div>
</div>
</LayoutContainer>
</section>
<PickupOnlyNotice />
{state.showSchedulePdf && <ScheduleDownloads />}
<UpcomingStops stops={state.stops} />
<ProductsSection products={state.products} />
<Testimonials />
<Newsletter />
<CTASection />
</main>
<StorefrontFooter
brandName={brand?.name ?? "Indian River Direct"}
brandName={state.brand?.name ?? "Indian River Direct"}
brandSlug="indian-river-direct"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
customFooterText={customFooterText}
contactEmail={contactEmail}
contactPhone={contactPhone}
isAdmin={isAdmin}
logoUrl={state.logoUrl}
logoUrlDark={state.logoUrlDark}
customFooterText={state.customFooterText}
contactEmail={state.contactEmail}
contactPhone={state.contactPhone}
isAdmin={state.isAdmin}
brandAccent="blue"
/>
</div>
);
}
function HeroSection({ heroTagline }: { heroTagline: string | null }) {
return (
<section className="relative bg-gradient-to-b from-blue-50 via-white to-stone-50 overflow-hidden min-h-[640px] flex items-center">
{/* Subtle decorative elements */}
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-100/40 rounded-full -translate-y-1/2 translate-x-1/4 blur-3xl" />
<div className="absolute bottom-0 left-0 w-80 h-80 bg-blue-50/60 rounded-full translate-y-1/2 -translate-x-1/4 blur-3xl" />
<LayoutContainer>
<div className="relative z-10 mx-auto max-w-4xl text-center pt-20 pb-16">
{/* Eyebrow */}
<motion.p
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-xs font-bold uppercase tracking-[0.3em] text-blue-600 mb-5"
>
Since 1985
</motion.p>
{/* Headline */}
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="text-5xl md:text-6xl lg:text-7xl font-black tracking-tight text-stone-950 leading-[1.05]"
>
Fresh From<br />The Grove
</motion.h1>
{/* Subheading */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.35 }}
className="mt-8 text-xl md:text-2xl text-stone-600 leading-relaxed max-w-2xl mx-auto"
>
{heroTagline ?? "Family-owned and operated. Bringing the finest peaches, pecans, and seasonal citrus from our Florida groves directly to your neighborhood."}
</motion.p>
{/* CTA Buttons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.5 }}
className="mt-12 flex gap-4 justify-center flex-wrap"
>
<Link
href="/indian-river-direct#stops"
className="rounded-2xl bg-blue-600 px-8 py-4 font-bold text-white hover:bg-blue-500 active:bg-blue-700 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-blue-900/20 transition-all text-sm tracking-wider"
>
Find a Stop
</Link>
<Link
href="/indian-river-direct#products"
className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all text-sm tracking-wider border border-blue-200"
>
Shop Products
</Link>
</motion.div>
</div>
</LayoutContainer>
{/* Scroll indicator */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.2, duration: 0.6 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2"
>
<motion.div
animate={{ y: [0, 8, 0] }}
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
className="flex flex-col items-center gap-2"
>
<span className="text-[10px] uppercase tracking-widest text-stone-400">Scroll</span>
<svg className="w-5 h-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</motion.div>
</motion.div>
</section>
);
}
function PickupOnlyNotice() {
return (
<FadeInSection className="py-16 bg-white relative">
<LayoutContainer>
<div className="mx-auto max-w-2xl">
<div className="rounded-3xl bg-gradient-to-br from-blue-50/80 to-stone-50/80 backdrop-blur-sm border border-blue-100/50 p-8 shadow-xl shadow-blue-200/30">
<div className="flex items-center justify-center gap-5">
<div className="flex-shrink-0">
<div className="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-600/30">
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
<div>
<h3 className="text-lg font-bold text-stone-950">Pickup Only Direct from the Truck</h3>
<p className="text-stone-600 text-sm mt-1">All products picked up at our scheduled truck stops across Ohio, Indiana, Kentucky, West Virginia, Wisconsin, and Illinois.</p>
</div>
</div>
</div>
</div>
</LayoutContainer>
</FadeInSection>
);
}
function ScheduleDownloads() {
return (
<FadeInSection className="py-20 bg-gradient-to-b from-blue-50/50 to-stone-50/50 relative">
<LayoutContainer>
<div className="mb-12">
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">2026 Season</p>
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">Pickup Schedule</h2>
<div className="mt-4 h-px w-12 bg-blue-600" />
<p className="mt-4 text-stone-600">Download our schedule to find stops in your area.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 max-w-2xl">
{SCHEDULE_PDFS.map((pdf) => (
<a key={pdf.region}
href={pdf.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-2xl bg-white border-2 border-stone-200 p-6 flex items-center gap-4 group shadow-lg hover:shadow-xl hover:border-blue-200 hover:-translate-y-1 transition-all duration-300"
>
<div className="flex-shrink-0">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center shadow-lg shadow-blue-600/30">
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div className="flex-1">
<h3 className="font-bold text-stone-950 text-lg">{pdf.region}</h3>
<p className="text-stone-500 text-sm">2026 Season Schedule</p>
</div>
<svg className="h-5 w-5 text-blue-500 group-hover:text-blue-700 group-hover:translate-x-1 transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
))}
</div>
</LayoutContainer>
</FadeInSection>
);
}
function UpcomingStops({ stops }: { stops: Stop[] }) {
return (
<FadeInSection className="py-20 bg-white relative">
<LayoutContainer>
<div className="mb-12">
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Family Farms</p>
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">Upcoming Stops</h2>
<div className="mt-4 h-px w-12 bg-blue-600" />
<p className="mt-4 text-stone-600">Find a pickup location near you.</p>
</div>
{stops.length === 0 ? (
<div className="rounded-3xl bg-stone-50 border-2 border-stone-200 p-12 text-center">
<p className="text-stone-500">No upcoming stops scheduled. Check back soon for the 2026 season!</p>
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{stops.slice(0, 6).map((stop) => (
<div key={stop.id} className="rounded-2xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-5 shadow-lg hover:shadow-xl hover:border-blue-200 hover:-translate-y-1 transition-all duration-300">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-bold text-stone-950 text-lg">{stop.city}, {stop.state}</h3>
<p className="text-stone-500 text-sm">{stop.location}</p>
</div>
<span className="shrink-0 rounded-full bg-blue-600 px-3 py-1 text-xs font-bold text-white shadow-sm">
Pickup
</span>
</div>
<div className="text-sm text-stone-600">
<p className="font-semibold">{stop.date}</p>
<p>{stop.time}</p>
</div>
</div>
))}
</div>
{stops.length > 6 && (
<div className="mt-8 text-center">
<Link
href="/indian-river-direct/stops"
className="inline-flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
View all stops
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
)}
</>
)}
</LayoutContainer>
</FadeInSection>
);
}
function ProductsSection({ products }: { products: Product[] }) {
return (
<FadeInSection className="py-20 bg-stone-100 relative">
<LayoutContainer>
<div className="mb-12">
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Fresh Products</p>
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-[1.05]">From Our Grove to You</h2>
<div className="mt-4 h-px w-12 bg-blue-600" />
<p className="mt-4 max-w-2xl text-lg text-stone-600 leading-relaxed">
Hand-picked at peak ripeness, delivered fresh from Titan Farms (South Carolina peaches) and Ellis Brothers (Georgia pecans) directly to your community.
</p>
</div>
{products.length === 0 ? (
<div className="rounded-3xl bg-white border-2 border-stone-200 p-12 text-center shadow-lg">
<p className="text-stone-500">Products coming soon for the 2026 season!</p>
</div>
) : (
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</LayoutContainer>
</FadeInSection>
);
}
function ProductCard({ product }: { product: Product }) {
return (
<div className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden shadow-xl hover:shadow-2xl hover:-translate-y-2 hover:border-blue-200 transition-all duration-300 group">
{/* Product Image */}
<div className="aspect-[4/3] bg-gradient-to-br from-blue-600 to-blue-700 flex items-center justify-center relative overflow-hidden">
{product.image_url ? (
<Image src={product.image_url} alt={product.name} fill className="object-cover transition-transform duration-500 group-hover:scale-105" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" />
) : (
<div className="text-6xl opacity-30 transition-transform duration-500 group-hover:scale-110">🍑</div>
)}
{/* Badges */}
<div className="absolute top-3 left-3 flex flex-wrap gap-2">
{product.preorder && (
<span className="rounded-full bg-white text-blue-700 px-3 py-1 text-xs font-bold shadow-lg">
Pre-Order
</span>
)}
{product.seasonal && (
<span className="rounded-full bg-stone-900/80 backdrop-blur-sm px-3 py-1 text-xs font-medium text-white">
{product.season_start} - {product.season_end}
</span>
)}
{product.pickup_only && (
<span className="rounded-full bg-blue-600 text-white px-3 py-1 text-xs font-medium shadow-md">
Pickup Only
</span>
)}
</div>
</div>
{/* Product Info */}
<div className="p-6">
<h3 className="text-lg font-bold text-stone-950 mb-2">{product.name}</h3>
<p className="text-stone-600 text-sm leading-relaxed line-clamp-2 mb-4">
{product.description}
</p>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-stone-950">
{product.price_tba ? "Price TBA" : `$${product.price.toFixed(2)}`}
</span>
{product.price > 0 && (
<button type="button" className="rounded-xl bg-blue-600 hover:bg-blue-500 active:bg-blue-700 px-5 py-2.5 text-sm font-bold text-white transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0">
{product.preorder ? "Pre-Order" : "Add to Cart"}
</button>
)}
</div>
</div>
</div>
);
}
function Testimonials() {
return (
<FadeInSection className="py-20 bg-gradient-to-b from-blue-50 to-blue-100/50 relative">
<LayoutContainer>
<div className="text-center mb-12">
<div className="flex items-center justify-center gap-3 mb-3">
<span className="h-px w-12 bg-gradient-to-r from-transparent to-blue-600" />
<div className="flex">
{[...Array(5)].map((_, i) => (
<svg key={i} className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
<span className="h-px w-12 bg-gradient-to-l from-transparent to-blue-600" />
</div>
<h2 className="text-3xl md:text-4xl font-black text-stone-950 tracking-tight">What Our Customers Say</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{TESTIMONIALS.map((t) => (
<div key={t.name} className="rounded-2xl bg-white border-2 border-stone-200 p-6 shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all duration-300">
<div className="flex mb-4">
{[...Array(t.rating)].map((_, j) => (
<svg key={j} className="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
<blockquote className="text-stone-600 text-sm leading-relaxed mb-4">
&quot;{t.text}&quot;
</blockquote>
<p className="text-stone-950 font-bold text-sm"> {t.name}</p>
</div>
))}
</div>
</LayoutContainer>
</FadeInSection>
);
}
function Newsletter() {
return (
<FadeInSection className="py-20 bg-stone-100 relative">
<LayoutContainer>
<div className="mx-auto max-w-2xl text-center">
<div className="rounded-3xl bg-gradient-to-br from-blue-600 to-blue-700 p-10 shadow-2xl shadow-blue-900/30 relative overflow-hidden">
{/* Decorative pattern */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
</div>
<div className="relative">
<h2 className="text-2xl md:text-3xl font-black text-white">Stay in the Loop</h2>
<p className="mt-2 text-blue-100">Get the annual schedules and tour updates delivered to your inbox.</p>
<form className="flex gap-3 max-w-md mx-auto mt-6">
<input aria-label="Your@email.com"
type="email"
placeholder="your@email.com"
className="flex-1 rounded-xl border-2 border-white/30 bg-white/90 backdrop-blur-sm px-4 py-3 text-stone-900 placeholder:text-stone-400 outline-none focus:border-white focus:ring-2 focus:ring-white/50 transition-all"
/>
<button type="button" className="rounded-xl bg-white text-blue-700 hover:bg-blue-50 px-6 py-3 font-bold transition-all shadow-lg hover:shadow-xl whitespace-nowrap">
Subscribe
</button>
</form>
</div>
</div>
</div>
</LayoutContainer>
</FadeInSection>
);
}
function CTASection() {
return (
<section className="py-20 bg-gradient-to-br from-blue-600 via-blue-700 to-blue-800 relative overflow-hidden">
{/* Decorative glow */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.1),transparent_50%)]" />
<LayoutContainer>
<div className="relative text-center">
<h2 className="text-2xl md:text-3xl font-black text-white">Ready to order?</h2>
<p className="mt-2 text-blue-100">Find a stop near you or browse our seasonal products.</p>
<div className="mt-8 flex gap-4 justify-center flex-wrap">
<Link href="/indian-river-direct#stops" className="rounded-2xl bg-white px-8 py-4 font-bold text-blue-600 hover:bg-blue-50 active:bg-blue-100 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
Find a Stop
</Link>
<Link href="/indian-river-direct/about" className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 px-8 py-4 font-bold text-white hover:bg-blue-600/80 hover:-translate-y-0.5 hover:shadow-xl shadow-sm transition-all duration-200 text-sm tracking-wider">
Our Story
</Link>
</div>
</div>
</LayoutContainer>
</section>
);
}
+74 -35
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useCallback, useState, useId, useTransition } from "react";
import { useCallback, useReducer, useId, useTransition } from "react";
import { devLoginAction } from "@/actions/auth-actions";
type LoginClientProps = {
@@ -13,66 +13,105 @@ const REDIRECT_URL = "/admin";
// Development mode detection
const isDev = process.env.NODE_ENV !== "production";
type State = {
email: string;
password: string;
loading: boolean;
googleLoading: boolean;
localError: string | null;
};
type Action =
| { type: "SET_EMAIL"; value: string }
| { type: "SET_PASSWORD"; value: string }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_GOOGLE_LOADING"; value: boolean }
| { type: "SET_LOCAL_ERROR"; value: string | null }
| { type: "RESET_FOR_RETRY" };
const initialState: State = {
email: "",
password: "",
loading: false,
googleLoading: false,
localError: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_EMAIL":
return { ...state, email: action.value };
case "SET_PASSWORD":
return { ...state, password: action.value };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_GOOGLE_LOADING":
return { ...state, googleLoading: action.value };
case "SET_LOCAL_ERROR":
return { ...state, localError: action.value };
case "RESET_FOR_RETRY":
return { ...state, localError: null };
}
}
export default function LoginClient({ error }: LoginClientProps) {
const emailId = useId();
const passwordId = useId();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, initialState);
const [, startDevLoginTransition] = useTransition();
async function handleSignIn(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!email || !password) {
setLocalError("Email and password are required.");
if (!state.email || !state.password) {
dispatch({ type: "SET_LOCAL_ERROR", value: "Email and password are required." });
return;
}
setLocalError(null);
setLoading(true);
dispatch({ type: "SET_LOCAL_ERROR", value: null });
dispatch({ type: "SET_LOADING", value: true });
try {
const res = await fetch("/api/auth/sign-in", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim().toLowerCase(), password: password.trim() }),
body: JSON.stringify({ email: state.email.trim().toLowerCase(), password: state.password.trim() }),
});
const data = await res.json();
if (!res.ok || data.error) {
setLocalError(data.message ?? "Sign-in failed. Please check your email and password.");
setLoading(false);
dispatch({ type: "SET_LOCAL_ERROR", value: data.message ?? "Sign-in failed. Please check your email and password." });
dispatch({ type: "SET_LOADING", value: false });
return;
}
// Cookie is set server-side via next/headers — just redirect on success.
window.location.href = REDIRECT_URL;
} catch {
setLocalError("Sign-in failed. Please try again.");
setLoading(false);
dispatch({ type: "SET_LOCAL_ERROR", value: "Sign-in failed. Please try again." });
dispatch({ type: "SET_LOADING", value: false });
}
}
async function handleGoogleSignIn() {
setGoogleLoading(true);
setLocalError(null);
dispatch({ type: "SET_GOOGLE_LOADING", value: true });
dispatch({ type: "SET_LOCAL_ERROR", value: null });
try {
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
const result = await signInWithGoogleAction({ callbackURL: REDIRECT_URL });
if (result.error || !result.url) {
setLocalError(
result.error ??
dispatch({
type: "SET_LOCAL_ERROR",
value:
result.error ??
"Google sign-in is not available. Contact your administrator or sign in with email + password.",
);
setGoogleLoading(false);
});
dispatch({ type: "SET_GOOGLE_LOADING", value: false });
return;
}
window.location.href = result.url;
} catch {
setLocalError("Google sign-in failed. Please try again.");
setGoogleLoading(false);
dispatch({ type: "SET_LOCAL_ERROR", value: "Google sign-in failed. Please try again." });
dispatch({ type: "SET_GOOGLE_LOADING", value: false });
}
}
@@ -86,7 +125,7 @@ export default function LoginClient({ error }: LoginClientProps) {
});
}, []);
const displayError = error ?? localError;
const displayError = error ?? state.localError;
return (
<main className="atelier-canvas relative min-h-screen flex flex-col overflow-hidden">
@@ -150,11 +189,11 @@ export default function LoginClient({ error }: LoginClientProps) {
<button
type="button"
onClick={handleGoogleSignIn}
disabled={googleLoading || loading}
disabled={state.googleLoading || state.loading}
className="atelier-cta atelier-cta-secondary w-full"
style={{ background: "white", color: "#1c1917", border: "1px solid #e7e5e4", boxShadow: "0 1px 2px rgba(0,0,0,0.04)" }}
>
{googleLoading ? (
{state.googleLoading ? (
<span className="relative z-10 inline-flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
@@ -195,9 +234,9 @@ export default function LoginClient({ error }: LoginClientProps) {
autoComplete="username"
className="atelier-input"
placeholder="you@yourfarm.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
value={state.email}
onChange={(e) => dispatch({ type: "SET_EMAIL", value: e.target.value })}
disabled={state.loading}
/>
</div>
<div className="flex flex-col gap-1.5">
@@ -216,9 +255,9 @@ export default function LoginClient({ error }: LoginClientProps) {
autoComplete="current-password"
className="atelier-input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
value={state.password}
onChange={(e) => dispatch({ type: "SET_PASSWORD", value: e.target.value })}
disabled={state.loading}
/>
</div>
@@ -236,11 +275,11 @@ export default function LoginClient({ error }: LoginClientProps) {
<button
type="submit"
disabled={loading}
disabled={state.loading}
className="atelier-cta w-full"
>
<span className="relative z-10">{loading ? "Signing in…" : "Sign in"}</span>
{!loading && (
<span className="relative z-10">{state.loading ? "Signing in…" : "Sign in"}</span>
{!state.loading && (
<svg className="h-4 w-4 relative z-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
+2 -2
View File
@@ -419,9 +419,9 @@ function Testimonials() {
</h2>
<p className="text-slate-500">
We&apos;re in early access with produce brands across the country. Customer stories coming soon &nbsp;
<a href="/contact" className="text-emerald-600 hover:underline font-medium">
<Link href="/contact" className="text-emerald-600 hover:underline font-medium">
talk to us
</a>
</Link>
&nbsp;if you&apos;d like to be a reference customer.
</p>
</div>
+61 -27
View File
@@ -1,54 +1,88 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import Link from "next/link";
type State = {
password: string;
confirm: string;
error: string | null;
loading: boolean;
done: boolean;
};
type Action =
| { type: "SET_PASSWORD"; value: string }
| { type: "SET_CONFIRM"; value: string }
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_DONE"; value: boolean };
const initialState: State = {
password: "",
confirm: "",
error: null,
loading: false,
done: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_PASSWORD":
return { ...state, password: action.value };
case "SET_CONFIRM":
return { ...state, confirm: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_DONE":
return { ...state, done: action.value };
}
}
export default function ResetPasswordPage() {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
dispatch({ type: "SET_ERROR", value: null });
if (password.length < 8) {
setError("Password must be at least 8 characters.");
if (state.password.length < 8) {
dispatch({ type: "SET_ERROR", value: "Password must be at least 8 characters." });
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
if (state.password !== state.confirm) {
dispatch({ type: "SET_ERROR", value: "Passwords do not match." });
return;
}
setLoading(true);
dispatch({ type: "SET_LOADING", value: true });
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
body: JSON.stringify({ password: state.password }),
});
const data = await res.json();
if (!res.ok || data.error) {
setError(data.message ?? "Failed to reset password. The link may have expired.");
setLoading(false);
dispatch({ type: "SET_ERROR", value: data.message ?? "Failed to reset password. The link may have expired." });
dispatch({ type: "SET_LOADING", value: false });
return;
}
setDone(true);
dispatch({ type: "SET_DONE", value: true });
} catch {
setError("An unexpected error occurred. Please try again.");
dispatch({ type: "SET_ERROR", value: "An unexpected error occurred. Please try again." });
} finally {
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
}
}
if (done) {
if (state.done) {
return (
<main className="min-h-screen bg-slate-100 px-6 py-12">
<div className="mx-auto max-w-md">
@@ -89,9 +123,9 @@ export default function ResetPasswordPage() {
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
{state.error && (
<div className="rounded-xl bg-red-50 p-4 text-sm text-red-700">
{error}
{state.error}
</div>
)}
@@ -99,8 +133,8 @@ export default function ResetPasswordPage() {
<label htmlFor="fld-1-new-password" className="block text-sm font-medium text-slate-700">New Password</label>
<input id="fld-1-new-password" aria-label=". 8 Characters"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
value={state.password}
onChange={(e) => dispatch({ type: "SET_PASSWORD", value: e.target.value })}
required
minLength={8}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
@@ -112,8 +146,8 @@ export default function ResetPasswordPage() {
<label htmlFor="fld-2-confirm-password" className="block text-sm font-medium text-slate-700">Confirm Password</label>
<input id="fld-2-confirm-password" aria-label="Repeat Password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
value={state.confirm}
onChange={(e) => dispatch({ type: "SET_CONFIRM", value: e.target.value })}
required
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
placeholder="Repeat password"
@@ -122,10 +156,10 @@ export default function ResetPasswordPage() {
<button
type="submit"
disabled={loading}
disabled={state.loading}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50"
>
{loading ? "Updating..." : "Update Password"}
{state.loading ? "Updating..." : "Update Password"}
</button>
</form>
</div>
+443 -343
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useReducer, useEffect, useRef } from "react";
import Link from "next/link";
import { m as motion, useInView } from "framer-motion";
import TuxedoVideoHero from "@/components/storefront/TuxedoVideoHero";
@@ -61,6 +61,102 @@ type Product = {
pickup_type?: "scheduled_stop" | "shed";
};
type State = {
brand: Brand | null;
stops: Stop[];
products: Product[];
wholesaleEnabled: boolean;
logoUrl: string | null;
logoUrlDark: string | null;
olatheSweetLogoUrl: string | null;
olatheSweetLogoUrlDark: string | null;
showSchedulePdf: boolean;
showWholesaleLink: boolean;
heroTagline: string | null;
isAdmin: boolean;
customFooterText: string | null;
contactEmail: string | null;
contactPhone: string | null;
brandPrimaryColor: string | null;
brandBgColor: string | null;
brandTextColor: string | null;
};
type Action =
| { type: "SET_BRAND"; brand: Brand | null }
| { type: "SET_STOPS"; stops: Stop[] }
| { type: "SET_PRODUCTS"; products: Product[] }
| {
type: "SET_SETTINGS";
wholesaleEnabled: boolean;
logoUrl: string | null;
logoUrlDark: string | null;
olatheSweetLogoUrl: string | null;
olatheSweetLogoUrlDark: string | null;
showSchedulePdf: boolean;
showWholesaleLink: boolean;
heroTagline: string | null;
customFooterText: string | null;
contactEmail: string | null;
contactPhone: string | null;
brandPrimaryColor: string | null;
brandBgColor: string | null;
brandTextColor: string | null;
}
| { type: "SET_IS_ADMIN"; isAdmin: boolean };
const initialState: State = {
brand: null,
stops: [],
products: [],
wholesaleEnabled: false,
logoUrl: null,
logoUrlDark: null,
olatheSweetLogoUrl: null,
olatheSweetLogoUrlDark: null,
showSchedulePdf: true,
showWholesaleLink: true,
heroTagline: null,
isAdmin: false,
customFooterText: null,
contactEmail: null,
contactPhone: null,
brandPrimaryColor: null,
brandBgColor: null,
brandTextColor: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_BRAND":
return { ...state, brand: action.brand };
case "SET_STOPS":
return { ...state, stops: action.stops };
case "SET_PRODUCTS":
return { ...state, products: action.products };
case "SET_SETTINGS":
return {
...state,
wholesaleEnabled: action.wholesaleEnabled,
logoUrl: action.logoUrl,
logoUrlDark: action.logoUrlDark,
olatheSweetLogoUrl: action.olatheSweetLogoUrl,
olatheSweetLogoUrlDark: action.olatheSweetLogoUrlDark,
showSchedulePdf: action.showSchedulePdf,
showWholesaleLink: action.showWholesaleLink,
heroTagline: action.heroTagline,
customFooterText: action.customFooterText,
contactEmail: action.contactEmail,
contactPhone: action.contactPhone,
brandPrimaryColor: action.brandPrimaryColor,
brandBgColor: action.brandBgColor,
brandTextColor: action.brandTextColor,
};
case "SET_IS_ADMIN":
return { ...state, isAdmin: action.isAdmin };
}
}
// ── Why Choose Tuxedo Corn — editorial masonry redesign ─────────────
type FeatureColor = "emerald" | "amber";
@@ -402,57 +498,314 @@ function WhyTuxedoCorn() {
);
}
// ── Section header ──────────────────────────────────────────────────
function SectionHeader({
eyebrow,
headline,
subtext,
accent = "emerald",
}: {
eyebrow: string;
headline: string;
subtext: string;
accent?: "emerald" | "stone";
}) {
// ── Wholesale link banner ───────────────────────────────────────────
function WholesaleBar({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<div className="mb-10 sm:mb-14">
<p className={`text-[10px] sm:text-[11px] font-semibold uppercase tracking-widest ${accent === "emerald" ? "text-emerald-600" : "text-stone-500"} mb-3 sm:mb-4`}>
{eyebrow}
</p>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
{headline}
</h2>
<div className="mt-5 sm:mt-6 h-px w-10 sm:w-12 bg-emerald-600" />
<p className="mt-5 sm:mt-6 max-w-2xl text-base sm:text-lg text-stone-500 leading-relaxed">
{subtext}
</p>
<div className="bg-stone-950 border-b border-zinc-800 py-3.5">
<div className="mx-auto max-w-6xl px-6">
<Link
href="/wholesale/login"
className="inline-flex items-center gap-2 rounded-full bg-emerald-700 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-600 active:bg-emerald-800 transition-all duration-200 hover:-translate-y-0.5"
>
Wholesale Portal
</Link>
</div>
</div>
);
}
// ── Stops section ───────────────────────────────────────────────────
function StopsSection({ stops, showSchedulePdf }: { stops: Stop[]; showSchedulePdf: boolean }) {
return (
<section id="stops" className="relative bg-gradient-to-b from-stone-100 to-stone-50 scroll-mt-20">
{/* Parallax background layers */}
<ParallaxLayer speed={0.2} className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 right-0 w-[40%] h-full bg-gradient-to-l from-emerald-50/30 to-transparent" />
</ParallaxLayer>
<div className="relative py-28">
<LayoutContainer>
<div className="mb-14 flex items-end justify-between">
<div>
<FadeOnScroll from="left">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Delivery Stops</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
Upcoming<br className="hidden md:block" /> Stops
</h2>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.15}>
<div className="mt-6 h-px w-12 bg-emerald-600" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}>
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
Find a nearby stop and preorder your corn. Pickup is easy and guaranteed.
</p>
</FadeOnScroll>
</div>
{showSchedulePdf && (
<FadeOnScroll from="right" delay={0.3}>
<Link
href="/api/tuxedo/schedule-pdf"
download
className="shrink-0 hidden md:inline-flex items-center gap-2.5 rounded-2xl bg-stone-900 px-6 py-3.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-950 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download Schedule
</Link>
</FadeOnScroll>
)}
</div>
<FadeOnScroll from="up" delay={0.3}>
{stops.length === 0 ? (
<div className="rounded-3xl bg-white p-12 sm:p-16 text-center ring-1 ring-stone-200/60">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-stone-100">
<svg className="h-7 w-7 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p className="text-lg font-semibold text-stone-800">No stops on the calendar just yet</p>
<p className="mx-auto mt-2 max-w-md text-stone-500">
The harvest is right around the corner new pickup stops go live weekly. You can still preorder below and pick a stop once they&#39;re announced.
</p>
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/tuxedo/stops"
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-5 py-2.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
>
View All Stops
</Link>
{showSchedulePdf && (
<Link
href="/api/tuxedo/schedule-pdf"
download
className="inline-flex items-center gap-2 rounded-2xl bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
>
Download Schedule
</Link>
)}
</div>
</div>
) : (
<PaginatedStops stops={stops} brandSlug="tuxedo" brandAccent="green" />
)}
</FadeOnScroll>
</LayoutContainer>
</div>
</section>
);
}
// ── Section divider ─────────────────────────────────────────────────
function SectionDivider() {
return (
<div className="relative h-24 bg-stone-50 overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-center gap-6">
<div className="h-px w-24 bg-gradient-to-r from-transparent via-stone-300 to-transparent" />
<svg className="h-6 w-6 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v13m0 0V8m0 13V4m0 9H4m8 0h8m-8-4h8" />
</svg>
<div className="h-px w-24 bg-gradient-to-r from-transparent via-stone-300 to-transparent" />
</div>
</div>
</div>
);
}
// ── Products section ────────────────────────────────────────────────
function ProductsSection({
products,
brandName,
olatheSweetLogoUrlDark,
}: {
products: Product[];
brandName: string;
olatheSweetLogoUrlDark: string | null;
}) {
return (
<section id="products" className="relative bg-white">
{/* Scroll-driven reveal for section header */}
<div className="py-20 bg-gradient-to-b from-stone-50 to-white">
<LayoutContainer>
<ScrollReveal from="up" className="mb-14">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Farm-Direct</p>
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
This Week&apos;s<br className="hidden md:block" /> Harvest
</h2>
<div className="mt-6 h-px w-12 bg-emerald-600" />
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season.
</p>
</ScrollReveal>
</LayoutContainer>
</div>
{/* Cinematic showcase replaces static grid */}
<CinematicShowcase
products={products.slice(0, 3).map((p) => ({
id: p.id,
name: p.name,
description: p.description ?? "",
price: `$${p.price}`,
type: p.type,
imageUrl: p.image_url,
brand_id: p.brand_id,
brand_slug: "tuxedo",
is_taxable: p.is_taxable,
pickup_type: p.pickup_type,
}))}
brandSlug="tuxedo"
brandName={brandName}
/>
{/* Mobile-friendly fallback grid for smaller screens */}
<div className="md:hidden py-16 bg-stone-50">
<LayoutContainer>
<div className="grid gap-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{products.slice(0, 6).map((product) => (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
description={product.description}
price={`$${product.price}`}
type={product.type}
imageUrl={product.image_url}
brandSlug="tuxedo"
brandName="Tuxedo Corn"
brandId={product.brand_id}
brandAccent="green"
is_taxable={product.is_taxable}
pickup_type={product.pickup_type}
olatheSweetLogoUrlDark={olatheSweetLogoUrlDark}
/>
))}
</div>
</LayoutContainer>
</div>
{products.length > 3 && (
<div className="mt-10 text-center pb-16">
<p className="text-sm text-stone-400">{products.length - 3} more products available in the full catalog.</p>
</div>
)}
</section>
);
}
// ── Story section ───────────────────────────────────────────────────
function StorySection() {
return (
<section id="story" className="py-32 bg-stone-950 relative overflow-hidden">
{/* Parallax decorative elements */}
<div className="absolute inset-0 pointer-events-none">
<ParallaxLayer speed={0.3} className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-5" style={{
background: "radial-gradient(circle, rgba(16,185,129,0.3) 0%, transparent 70%)",
filter: "blur(8px)",
}}>
<div />
</ParallaxLayer>
<ParallaxLayer speed={0.5} className="absolute bottom-1/4 right-1/4 w-64 h-64 rounded-full opacity-5" style={{
background: "radial-gradient(circle, rgba(255,215,0,0.3) 0%, transparent 70%)",
filter: "blur(8px)",
}}>
<div />
</ParallaxLayer>
</div>
{/* Large background text for parallax depth */}
<div className="absolute inset-0 flex items-center justify-center overflow-hidden opacity-5 pointer-events-none">
<ParallaxLayer speed={0.2}>
<span className="text-[20vw] font-black text-white leading-none">SINCE</span>
</ParallaxLayer>
</div>
<LayoutContainer>
<div className="text-center max-w-3xl mx-auto relative z-10">
<FadeOnScroll from="up" className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-400/60 mb-4">Our Story</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-white leading-[1.05] mb-8">
Three Generations of<br className="hidden md:block" /> Sweet Corn Excellence
</h2>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}>
<div className="mx-auto mt-8 mb-10 h-px w-16 bg-gradient-to-r from-emerald-600 to-amber-500" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.3}>
<p className="text-stone-400 leading-relaxed text-lg md:text-xl max-w-2xl mx-auto">
Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn developed for Colorado&apos;s high-altitude mountain climate and grown by the same family for over 40 years.
</p>
</FadeOnScroll>
{/* Stats with counter animation */}
<FadeOnScroll from="up" delay={0.4}>
<div className="flex items-center justify-center gap-12 md:gap-16 mt-16 flex-wrap">
{[
{ stat: "40", suffix: "+", label: "Years Growing" },
{ stat: "3", suffix: "", label: "Generations" },
{ stat: "100", suffix: "%", label: "Hand-Picked" },
].map((item) => (
<div key={item.label} className="text-center">
<div className="text-4xl md:text-5xl lg:text-6xl font-black text-white" style={{ textShadow: "0 4px 30px rgba(16,185,129,0.3)" }}>
<span className="counter-animate" data-target={parseInt(item.stat, 10)}>0</span>
<span style={{ color: "#10b981" }}>{item.suffix}</span>
</div>
<div className="mt-2 text-[10px] uppercase tracking-[0.2em] text-stone-500">
{item.label}
</div>
</div>
))}
</div>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.5}>
<div className="mt-16">
<Link
href="/tuxedo/about"
className="group inline-flex items-center gap-3 rounded-full px-10 py-4 text-sm font-bold transition-all duration-300"
style={{
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-3px)";
e.currentTarget.style.boxShadow = "0 12px 40px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)";
}}
>
<span>Read Our Story</span>
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</FadeOnScroll>
</div>
</LayoutContainer>
</section>
);
}
export default function TuxedoPage() {
const [brand, setBrand] = useState<Brand | null>(null);
const [stops, setStops] = useState<Stop[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [wholesaleEnabled, setWholesaleEnabled] = useState(false);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
const [olatheSweetLogoUrl, setOlatheSweetLogoUrl] = useState<string | null>(null);
const [olatheSweetLogoUrlDark, setOlatheSweetLogoUrlDark] = useState<string | null>(null);
const [showSchedulePdf, setShowSchedulePdf] = useState(true);
const [showWholesaleLink, setShowWholesaleLink] = useState(true);
const [heroTagline, setHeroTagline] = useState<string | null>(null);
const heroImageUrlRef = useRef<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [customFooterText, setCustomFooterText] = useState<string | null>(null);
const [contactEmail, setContactEmail] = useState<string | null>(null);
const [contactPhone, setContactPhone] = useState<string | null>(null);
const [brandPrimaryColor, setBrandPrimaryColor] = useState<string | null>(null);
const [brandBgColor, setBrandBgColor] = useState<string | null>(null);
const [brandTextColor, setBrandTextColor] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, initialState);
// Scope ref for GSAP context (fixes "invalid scope"/missing targets by providing explicit scope; selectors now limited to page)
const pageScopeRef = useRef<HTMLDivElement>(null);
// hero image url is read once from settings and never re-rendered; keep as ref to preserve original behavior
const heroImageUrlRef = useRef<string | null>(null);
useEffect(() => {
async function load() {
@@ -464,31 +817,34 @@ export default function TuxedoPage() {
]);
const brandData = brandResult.data as Brand | null;
setBrand(brandData);
dispatch({ type: "SET_BRAND", brand: brandData });
if (settingsResult.success && settingsResult.settings) {
const s = settingsResult.settings;
setLogoUrl(s.logo_url ?? null);
setLogoUrlDark(s.logo_url_dark ?? null);
setOlatheSweetLogoUrl(s.olathe_sweet_logo_url ?? null);
setOlatheSweetLogoUrlDark(s.olathe_sweet_logo_url_dark ?? null);
heroImageUrlRef.current = s.hero_image_url ?? null;
setHeroTagline(s.hero_tagline ?? null);
setCustomFooterText(s.custom_footer_text ?? null);
setContactEmail(s.email ?? null);
setContactPhone(s.phone ?? null);
setShowSchedulePdf(s.show_schedule_pdf ?? true);
setShowWholesaleLink(s.show_wholesale_link ?? true);
setWholesaleEnabled(settingsResult.wholesaleEnabled ?? false);
setBrandPrimaryColor(s.brand_primary_color ?? null);
setBrandBgColor(s.brand_bg_color ?? null);
setBrandTextColor(s.brand_text_color ?? null);
dispatch({
type: "SET_SETTINGS",
wholesaleEnabled: settingsResult.wholesaleEnabled ?? false,
logoUrl: s.logo_url ?? null,
logoUrlDark: s.logo_url_dark ?? null,
olatheSweetLogoUrl: s.olathe_sweet_logo_url ?? null,
olatheSweetLogoUrlDark: s.olathe_sweet_logo_url_dark ?? null,
showSchedulePdf: s.show_schedule_pdf ?? true,
showWholesaleLink: s.show_wholesale_link ?? true,
heroTagline: s.hero_tagline ?? null,
customFooterText: s.custom_footer_text ?? null,
contactEmail: s.email ?? null,
contactPhone: s.phone ?? null,
brandPrimaryColor: s.brand_primary_color ?? null,
brandBgColor: s.brand_bg_color ?? null,
brandTextColor: s.brand_text_color ?? null,
});
}
try {
const { getCurrentAdminUser } = await import("@/actions/admin-user");
const adminUser = await getCurrentAdminUser();
setIsAdmin(!!adminUser);
dispatch({ type: "SET_IS_ADMIN", isAdmin: !!adminUser });
} catch {
// not logged in as admin
}
@@ -498,8 +854,8 @@ export default function TuxedoPage() {
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
]);
setStops(stopsData ?? []);
setProducts(productsData ?? []);
dispatch({ type: "SET_STOPS", stops: stopsData ?? [] });
dispatch({ type: "SET_PRODUCTS", products: productsData ?? [] });
}
}
load();
@@ -620,42 +976,31 @@ export default function TuxedoPage() {
return (
<div ref={pageScopeRef} className="min-h-screen bg-stone-50">
<BrandStylesProvider
primaryColor={brandPrimaryColor}
bgColor={brandBgColor}
textColor={brandTextColor}
primaryColor={state.brandPrimaryColor}
bgColor={state.brandBgColor}
textColor={state.brandTextColor}
/>
<StorefrontHeader
brandName={brand?.name ?? "Tuxedo Corn"}
brandName={state.brand?.name ?? "Tuxedo Corn"}
brandSlug="tuxedo"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
showWholesaleLink={showWholesaleLink}
isAdmin={isAdmin}
logoUrl={state.logoUrl}
logoUrlDark={state.logoUrlDark}
showWholesaleLink={state.showWholesaleLink}
isAdmin={state.isAdmin}
brandAccent="green"
/>
<main>
{showWholesaleLink && wholesaleEnabled && (
<div className="bg-stone-950 border-b border-zinc-800 py-3.5">
<div className="mx-auto max-w-6xl px-6">
<Link
href="/wholesale/login"
className="inline-flex items-center gap-2 rounded-full bg-emerald-700 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-600 active:bg-emerald-800 transition-all duration-200 hover:-translate-y-0.5"
>
Wholesale Portal
</Link>
</div>
</div>
)}
<WholesaleBar visible={state.showWholesaleLink && state.wholesaleEnabled} />
<TuxedoVideoHero
eyebrow="Olathe Sweet™ — Olathe, Colorado"
title="Tuxedo Corn"
description={
heroTagline ??
state.heroTagline ??
"Premium Olathe Sweet™ sweet corn — hand-picked at peak freshness from our family farm in Colorado."
}
olatheSweetLogoUrl={olatheSweetLogoUrl}
olatheSweetLogoUrl={state.olatheSweetLogoUrl}
primaryButton="Find a Stop"
secondaryButton="Our Story"
onPrimaryClick={scrollToStops}
@@ -664,273 +1009,28 @@ export default function TuxedoPage() {
<WhyTuxedoCorn />
<section id="stops" className="relative bg-gradient-to-b from-stone-100 to-stone-50 scroll-mt-20">
{/* Parallax background layers */}
<ParallaxLayer speed={0.2} className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 right-0 w-[40%] h-full bg-gradient-to-l from-emerald-50/30 to-transparent" />
</ParallaxLayer>
<StopsSection stops={state.stops} showSchedulePdf={state.showSchedulePdf} />
<div className="relative py-28">
<LayoutContainer>
<div className="mb-14 flex items-end justify-between">
<div>
<FadeOnScroll from="left">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Delivery Stops</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
Upcoming<br className="hidden md:block" /> Stops
</h2>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.15}>
<div className="mt-6 h-px w-12 bg-emerald-600" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}>
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
Find a nearby stop and preorder your corn. Pickup is easy and guaranteed.
</p>
</FadeOnScroll>
</div>
{showSchedulePdf && (
<FadeOnScroll from="right" delay={0.3}>
<Link
href="/api/tuxedo/schedule-pdf"
download
className="shrink-0 hidden md:inline-flex items-center gap-2.5 rounded-2xl bg-stone-900 px-6 py-3.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-950 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download Schedule
</Link>
</FadeOnScroll>
)}
</div>
<FadeOnScroll from="up" delay={0.3}>
{stops.length === 0 ? (
<div className="rounded-3xl bg-white p-12 sm:p-16 text-center ring-1 ring-stone-200/60">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-stone-100">
<svg className="h-7 w-7 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p className="text-lg font-semibold text-stone-800">No stops on the calendar just yet</p>
<p className="mx-auto mt-2 max-w-md text-stone-500">
The harvest is right around the corner new pickup stops go live weekly. You can still preorder below and pick a stop once they&#39;re announced.
</p>
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/tuxedo/stops"
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-5 py-2.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
>
View All Stops
</Link>
{showSchedulePdf && (
<Link
href="/api/tuxedo/schedule-pdf"
download
className="inline-flex items-center gap-2 rounded-2xl bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
>
Download Schedule
</Link>
)}
</div>
</div>
) : (
<PaginatedStops stops={stops} brandSlug="tuxedo" brandAccent="green" />
)}
</FadeOnScroll>
</LayoutContainer>
</div>
</section>
<SectionDivider />
{/* Section divider */}
<div className="relative h-24 bg-stone-50 overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-center gap-6">
<div className="h-px w-24 bg-gradient-to-r from-transparent via-stone-300 to-transparent" />
<svg className="h-6 w-6 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v13m0 0V8m0 13V4m0 9H4m8 0h8m-8-4h8" />
</svg>
<div className="h-px w-24 bg-gradient-to-r from-transparent via-stone-300 to-transparent" />
</div>
</div>
</div>
<ProductsSection
products={state.products}
brandName={state.brand?.name ?? "Tuxedo Corn"}
olatheSweetLogoUrlDark={state.olatheSweetLogoUrlDark}
/>
<section id="products" className="relative bg-white">
{/* Scroll-driven reveal for section header */}
<div className="py-20 bg-gradient-to-b from-stone-50 to-white">
<LayoutContainer>
<ScrollReveal from="up" className="mb-14">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Farm-Direct</p>
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
This Week&apos;s<br className="hidden md:block" /> Harvest
</h2>
<div className="mt-6 h-px w-12 bg-emerald-600" />
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season.
</p>
</ScrollReveal>
</LayoutContainer>
</div>
{/* Cinematic showcase replaces static grid */}
<CinematicShowcase
products={products.slice(0, 3).map((p) => ({
id: p.id,
name: p.name,
description: p.description ?? "",
price: `$${p.price}`,
type: p.type,
imageUrl: p.image_url,
brand_id: p.brand_id,
brand_slug: "tuxedo",
is_taxable: p.is_taxable,
pickup_type: p.pickup_type,
}))}
brandSlug="tuxedo"
brandName={brand?.name ?? "Tuxedo Corn"}
/>
{/* Mobile-friendly fallback grid for smaller screens */}
<div className="md:hidden py-16 bg-stone-50">
<LayoutContainer>
<div className="grid gap-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{products.slice(0, 6).map((product) => (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
description={product.description}
price={`$${product.price}`}
type={product.type}
imageUrl={product.image_url}
brandSlug="tuxedo"
brandName="Tuxedo Corn"
brandId={product.brand_id}
brandAccent="green"
is_taxable={product.is_taxable}
pickup_type={product.pickup_type}
olatheSweetLogoUrlDark={olatheSweetLogoUrlDark}
/>
))}
</div>
</LayoutContainer>
</div>
{products.length > 3 && (
<div className="mt-10 text-center pb-16">
<p className="text-sm text-stone-400">{products.length - 3} more products available in the full catalog.</p>
</div>
)}
</section>
<section id="story" className="py-32 bg-stone-950 relative overflow-hidden">
{/* Parallax decorative elements */}
<div className="absolute inset-0 pointer-events-none">
<ParallaxLayer speed={0.3} className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-5" style={{
background: "radial-gradient(circle, rgba(16,185,129,0.3) 0%, transparent 70%)",
filter: "blur(8px)",
}}>
<div />
</ParallaxLayer>
<ParallaxLayer speed={0.5} className="absolute bottom-1/4 right-1/4 w-64 h-64 rounded-full opacity-5" style={{
background: "radial-gradient(circle, rgba(255,215,0,0.3) 0%, transparent 70%)",
filter: "blur(8px)",
}}>
<div />
</ParallaxLayer>
</div>
{/* Large background text for parallax depth */}
<div className="absolute inset-0 flex items-center justify-center overflow-hidden opacity-5 pointer-events-none">
<ParallaxLayer speed={0.2}>
<span className="text-[20vw] font-black text-white leading-none">SINCE</span>
</ParallaxLayer>
</div>
<LayoutContainer>
<div className="text-center max-w-3xl mx-auto relative z-10">
<FadeOnScroll from="up" className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-400/60 mb-4">Our Story</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-white leading-[1.05] mb-8">
Three Generations of<br className="hidden md:block" /> Sweet Corn Excellence
</h2>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}>
<div className="mx-auto mt-8 mb-10 h-px w-16 bg-gradient-to-r from-emerald-600 to-amber-500" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.3}>
<p className="text-stone-400 leading-relaxed text-lg md:text-xl max-w-2xl mx-auto">
Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn developed for Colorado&apos;s high-altitude mountain climate and grown by the same family for over 40 years.
</p>
</FadeOnScroll>
{/* Stats with counter animation */}
<FadeOnScroll from="up" delay={0.4}>
<div className="flex items-center justify-center gap-12 md:gap-16 mt-16 flex-wrap">
{[
{ stat: "40", suffix: "+", label: "Years Growing" },
{ stat: "3", suffix: "", label: "Generations" },
{ stat: "100", suffix: "%", label: "Hand-Picked" },
].map((item) => (
<div key={item.label} className="text-center">
<div className="text-4xl md:text-5xl lg:text-6xl font-black text-white" style={{ textShadow: "0 4px 30px rgba(16,185,129,0.3)" }}>
<span className="counter-animate" data-target={parseInt(item.stat, 10)}>0</span>
<span style={{ color: "#10b981" }}>{item.suffix}</span>
</div>
<div className="mt-2 text-[10px] uppercase tracking-[0.2em] text-stone-500">
{item.label}
</div>
</div>
))}
</div>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.5}>
<div className="mt-16">
<Link
href="/tuxedo/about"
className="group inline-flex items-center gap-3 rounded-full px-10 py-4 text-sm font-bold transition-all duration-300"
style={{
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-3px)";
e.currentTarget.style.boxShadow = "0 12px 40px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)";
}}
>
<span>Read Our Story</span>
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</FadeOnScroll>
</div>
</LayoutContainer>
</section>
<StorySection />
</main>
<StorefrontFooter
brandName={brand?.name ?? "Tuxedo Corn"}
brandName={state.brand?.name ?? "Tuxedo Corn"}
brandSlug="tuxedo"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
customFooterText={customFooterText}
contactEmail={contactEmail}
contactPhone={contactPhone}
isAdmin={isAdmin}
logoUrl={state.logoUrl}
logoUrlDark={state.logoUrlDark}
customFooterText={state.customFooterText}
contactEmail={state.contactEmail}
contactPhone={state.contactPhone}
isAdmin={state.isAdmin}
brandAccent="green"
/>
</div>
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { useReducer, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
type WholesaleOrder,
@@ -8,7 +8,6 @@ import {
markWholesaleOrderFulfilled,
} from "@/actions/wholesale";
import { wholesaleEmployeeLogoutAction } from "@/actions/wholesale-auth";
import { usePrevious } from "@/lib/use-prev";
import { openHtmlInPopup } from "@/lib/safe-window";
import DepositModal from "@/components/wholesale/DepositModal";
import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal";
@@ -30,6 +29,86 @@ const STATUS_BADGE_MAP: Record<string, string> = {
cancelled: "bg-red-100 text-red-700",
};
type Msg = { kind: "success" | "error"; text: string };
type State = {
orders: WholesaleOrder[];
activeTab: Queue;
showViewOrder: WholesaleOrder | null;
showDepForm: string | null;
fulfilling: string | null;
openActions: string | null;
msg: Msg | null;
};
type Action =
| { type: "SET_ORDERS"; orders: WholesaleOrder[] }
| { type: "SET_ACTIVE_TAB"; tab: Queue }
| { type: "SET_SHOW_VIEW_ORDER"; order: WholesaleOrder | null }
| { type: "SET_SHOW_DEP_FORM"; id: string | null }
| { type: "SET_FULFILLING"; id: string | null }
| { type: "TOGGLE_ACTIONS"; id: string }
| { type: "CLOSE_ACTIONS" }
| { type: "SET_MSG"; msg: Msg }
| { type: "CLEAR_MSG" };
const initialState = (initialOrders: WholesaleOrder[]): State => ({
orders: initialOrders,
activeTab: "today",
showViewOrder: null,
showDepForm: null,
fulfilling: null,
openActions: null,
msg: null,
});
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_ORDERS":
return { ...state, orders: action.orders };
case "SET_ACTIVE_TAB":
return { ...state, activeTab: action.tab };
case "SET_SHOW_VIEW_ORDER":
return { ...state, showViewOrder: action.order };
case "SET_SHOW_DEP_FORM":
return { ...state, showDepForm: action.id };
case "SET_FULFILLING":
return { ...state, fulfilling: action.id };
case "TOGGLE_ACTIONS":
return {
...state,
openActions: state.openActions === action.id ? null : action.id,
};
case "CLOSE_ACTIONS":
return { ...state, openActions: null };
case "SET_MSG":
return { ...state, msg: action.msg };
case "CLEAR_MSG":
return { ...state, msg: null };
}
}
const TAB_STYLES: Record<Queue, { activeBorder: string; activeText: string; countBg: string; countText: string }> = {
past_due: {
activeBorder: "border-red-500",
activeText: "text-red-700",
countBg: "bg-red-100",
countText: "text-red-700",
},
today: {
activeBorder: "border-yellow-500",
activeText: "text-yellow-700",
countBg: "bg-yellow-100",
countText: "text-yellow-700",
},
upcoming: {
activeBorder: "border-blue-500",
activeText: "text-blue-700",
countBg: "bg-blue-100",
countText: "text-blue-700",
},
};
function StatusBadge({ status }: { status: string }) {
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE_MAP[status] ?? "bg-slate-100 text-slate-600"}`}>
@@ -40,27 +119,14 @@ function StatusBadge({ status }: { status: string }) {
export default function EmployeePortalClient({ brandId, brandName, employeeName, initialOrders }: Props) {
const router = useRouter();
const [orders, setOrders] = useState<WholesaleOrder[]>(initialOrders);
const [activeTab, setActiveTab] = useState<Queue>("today");
const [showViewOrder, setShowViewOrder] = useState<WholesaleOrder | null>(null);
const [showDepForm, setShowDepForm] = useState<string | null>(null);
const [fulfilling, setFulfilling] = useState<string | null>(null);
const [openActions, setOpenActions] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [state, dispatch] = useReducer(reducer, initialState(initialOrders));
const [, startTransition] = useTransition();
async function handleSignOut() {
startTransition(async () => {
await wholesaleEmployeeLogoutAction();
router.push("/wholesale/login");
});
}
// Close dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".actions-cell")) {
setOpenActions(null);
dispatch({ type: "CLOSE_ACTIONS" });
}
}
document.addEventListener("click", handleClick);
@@ -69,172 +135,299 @@ export default function EmployeePortalClient({ brandId, brandName, employeeName,
const today = new Date().toISOString().split("T")[0];
const pastDue = orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date < today);
const todayOrders = orders.filter((o) => o.anticipated_pickup_date === today);
const upcoming = orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date > today);
const pastDue = state.orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date < today);
const todayOrders = state.orders.filter((o) => o.anticipated_pickup_date === today);
const upcoming = state.orders.filter((o) => o.anticipated_pickup_date && o.anticipated_pickup_date > today);
const tabData: Record<Queue, { label: string; orders: WholesaleOrder[]; color: string; bg: string }> = {
past_due: { label: "Past Due", orders: pastDue, color: "text-red-700", bg: "bg-red-50 border-red-200" },
today: { label: "Today", orders: todayOrders, color: "text-yellow-700", bg: "bg-yellow-50 border-yellow-200" },
upcoming: { label: "Upcoming", orders: upcoming, color: "text-blue-700", bg: "bg-blue-50 border-blue-200" },
const tabData: Record<Queue, { label: string; orders: WholesaleOrder[] }> = {
past_due: { label: "Past Due", orders: pastDue },
today: { label: "Today", orders: todayOrders },
upcoming: { label: "Upcoming", orders: upcoming },
};
const currentTab = tabData[activeTab];
const currentTab = tabData[state.activeTab];
async function handleSignOut() {
startTransition(async () => {
await wholesaleEmployeeLogoutAction();
router.push("/wholesale/login");
});
}
async function handleFulfill(orderId: string) {
setFulfilling(orderId);
dispatch({ type: "SET_FULFILLING", id: orderId });
const result = await markWholesaleOrderFulfilled(orderId);
setFulfilling(null);
dispatch({ type: "SET_FULFILLING", id: null });
if (result.success) {
setMsg({ kind: "success", text: "Order marked as fulfilled." });
dispatch({ type: "SET_MSG", msg: { kind: "success", text: "Order marked as fulfilled." } });
const data = await getWholesalePickupOrders(brandId);
setOrders(data);
dispatch({ type: "SET_ORDERS", orders: data });
} else {
setMsg({ kind: "error", text: result.error ?? "Failed to fulfill order." });
dispatch({ type: "SET_MSG", msg: { kind: "error", text: result.error ?? "Failed to fulfill order." } });
}
setTimeout(() => setMsg(null), 4000);
setTimeout(() => dispatch({ type: "CLEAR_MSG" }), 4000);
}
return (
<div className="min-h-screen bg-slate-100">
{/* Header */}
<div className="bg-white border-b border-slate-200 px-4 py-3 sm:px-6 sm:py-4">
<div className="mx-auto max-w-5xl flex items-center justify-between gap-4">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 truncate">Pickup Portal</h1>
<p className="mt-0.5 text-xs sm:text-sm text-slate-500 truncate">{brandName}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs sm:text-sm text-slate-500 hidden sm:inline">{employeeName}</span>
<button
type="button"
onClick={handleSignOut}
className="rounded-xl border border-slate-300 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors min-h-[40px]"
>
Sign Out
</button>
</div>
</div>
</div>
<PortalHeader
brandName={brandName}
employeeName={employeeName}
onSignOut={handleSignOut}
/>
{/* Queue tabs */}
<div className="bg-white border-b border-slate-200 px-4 sm:px-6">
<div className="mx-auto max-w-5xl">
<nav className="flex gap-1 -mb-px overflow-x-auto">
{(["past_due", "today", "upcoming"] as Queue[]).map((tab) => {
const data = tabData[tab];
const isActive = activeTab === tab;
return (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
isActive
? tab === "past_due" ? "border-red-500 text-red-700" :
tab === "today" ? "border-yellow-500 text-yellow-700" :
"border-blue-500 text-blue-700"
: "border-transparent text-slate-500 hover:text-slate-700"
}`}
>
{data.label}
<span className={`rounded-full px-2 py-0.5 text-xs font-bold ${
tab === "past_due" ? "bg-red-100 text-red-700" :
tab === "today" ? "bg-yellow-100 text-yellow-700" :
"bg-blue-100 text-blue-700"
}`}>
{data.orders.length}
</span>
</button>
);
})}
</nav>
</div>
</div>
<QueueTabs
activeTab={state.activeTab}
tabData={tabData}
onChange={(tab) => dispatch({ type: "SET_ACTIVE_TAB", tab })}
/>
<div className="mx-auto max-w-5xl px-6 py-6">
{msg && (
<div className={`mb-4 rounded-xl border px-4 py-3 text-sm ${
msg.kind === "success" ? "border-green-200 bg-green-50 text-green-700" : "border-red-200 bg-red-50 text-red-700"
}`}>
{msg.text}
</div>
)}
<MessageBanner msg={state.msg} />
{currentTab.orders.length === 0 ? (
<EmptyQueueState
label={currentTab.label === "Past Due" ? "No past due orders" :
currentTab.label === "Today" ? "No pickups scheduled today" :
"No upcoming pickups"}
description={currentTab.label === "Past Due" ?
"All orders are on schedule. Great work!" :
currentTab.label === "Today" ?
"There are no wholesale orders scheduled for pickup today." :
"You have no upcoming pickups scheduled."}
label={
currentTab.label === "Past Due"
? "No past due orders"
: currentTab.label === "Today"
? "No pickups scheduled today"
: "No upcoming pickups"
}
description={
currentTab.label === "Past Due"
? "All orders are on schedule. Great work!"
: currentTab.label === "Today"
? "There are no wholesale orders scheduled for pickup today."
: "You have no upcoming pickups scheduled."
}
/>
) : (
<div className="space-y-3">
{currentTab.orders.map((order) => (
<OrderCard
key={order.id}
order={order}
fulfilling={fulfilling}
openActions={openActions}
onToggleActions={() => {
setOpenActions(openActions === order.id ? null : order.id);
}}
onFulfill={handleFulfill}
onRecordDeposit={() => setShowDepForm(order.id)}
onViewDetails={() => setShowViewOrder(order)}
onGenerateManifest={() => {
fetch("/api/wholesale/manifest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, orders: [order] }),
}).then(r => r.text()).then(html => openHtmlInPopup(html));
}}
onSendPriceSheet={() => {
fetch("/api/wholesale/price-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerIds: [order.customer_id], brandId: "" }),
}).then(r => r.json()).then(d => {
setMsg({ kind: "success", text: `Price sheet sent to ${order.company_name}.` });
}).catch(() => setMsg({ kind: "error", text: "Failed to send price sheet." }));
}}
/>
))}
</div>
<OrdersList
orders={currentTab.orders}
fulfilling={state.fulfilling}
openActions={state.openActions}
onToggleActions={(id) => dispatch({ type: "TOGGLE_ACTIONS", id })}
onFulfill={handleFulfill}
onRecordDeposit={(id) => dispatch({ type: "SET_SHOW_DEP_FORM", id })}
onViewDetails={(order) => dispatch({ type: "SET_SHOW_VIEW_ORDER", order })}
onSendPriceSheet={(order) => {
fetch("/api/wholesale/price-sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerIds: [order.customer_id], brandId: "" }),
})
.then((r) => r.json())
.then(() => {
dispatch({
type: "SET_MSG",
msg: { kind: "success", text: `Price sheet sent to ${order.company_name}.` },
});
})
.catch(() => {
dispatch({ type: "SET_MSG", msg: { kind: "error", text: "Failed to send price sheet." } });
});
}}
onGenerateManifest={(order) => {
fetch("/api/wholesale/manifest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, orders: [order] }),
})
.then((r) => r.text())
.then((html) => openHtmlInPopup(html));
}}
/>
)}
</div>
{/* Modals */}
{showViewOrder && (
<OrderDetailsModal
order={showViewOrder}
onClose={() => setShowViewOrder(null)}
onFulfill={handleFulfill}
onRecordDeposit={(id) => { setShowViewOrder(null); setShowDepForm(id); }}
<PortalModals
state={state}
dispatch={dispatch}
brandId={brandId}
handleFulfill={handleFulfill}
/>
</div>
);
}
// ── Portal Header ─────────────────────────────────────────────────────────────
function PortalHeader({
brandName,
employeeName,
onSignOut,
}: {
brandName: string;
employeeName: string;
onSignOut: () => void;
}) {
return (
<div className="bg-white border-b border-slate-200 px-4 py-3 sm:px-6 sm:py-4">
<div className="mx-auto max-w-5xl flex items-center justify-between gap-4">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 truncate">Pickup Portal</h1>
<p className="mt-0.5 text-xs sm:text-sm text-slate-500 truncate">{brandName}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs sm:text-sm text-slate-500 hidden sm:inline">{employeeName}</span>
<button
type="button"
onClick={onSignOut}
className="rounded-xl border border-slate-300 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors min-h-[40px]"
>
Sign Out
</button>
</div>
</div>
</div>
);
}
// ── Queue Tabs ────────────────────────────────────────────────────────────────
function QueueTabs({
activeTab,
tabData,
onChange,
}: {
activeTab: Queue;
tabData: Record<Queue, { label: string; orders: WholesaleOrder[] }>;
onChange: (tab: Queue) => void;
}) {
return (
<div className="bg-white border-b border-slate-200 px-4 sm:px-6">
<div className="mx-auto max-w-5xl">
<nav className="flex gap-1 -mb-px overflow-x-auto">
{(["past_due", "today", "upcoming"] as Queue[]).map((tab) => {
const data = tabData[tab];
const isActive = activeTab === tab;
const styles = TAB_STYLES[tab];
return (
<button
key={tab}
type="button"
onClick={() => onChange(tab)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
isActive
? `${styles.activeBorder} ${styles.activeText}`
: "border-transparent text-slate-500 hover:text-slate-700"
}`}
>
{data.label}
<span className={`rounded-full px-2 py-0.5 text-xs font-bold ${styles.countBg} ${styles.countText}`}>
{data.orders.length}
</span>
</button>
);
})}
</nav>
</div>
</div>
);
}
// ── Message Banner ────────────────────────────────────────────────────────────
function MessageBanner({ msg }: { msg: Msg | null }) {
if (!msg) return null;
return (
<div
className={`mb-4 rounded-xl border px-4 py-3 text-sm ${
msg.kind === "success"
? "border-green-200 bg-green-50 text-green-700"
: "border-red-200 bg-red-50 text-red-700"
}`}
>
{msg.text}
</div>
);
}
// ── Orders List ───────────────────────────────────────────────────────────────
type OrdersListProps = {
orders: WholesaleOrder[];
fulfilling: string | null;
openActions: string | null;
onToggleActions: (id: string) => void;
onFulfill: (id: string) => void;
onRecordDeposit: (id: string) => void;
onViewDetails: (order: WholesaleOrder) => void;
onGenerateManifest: (order: WholesaleOrder) => void;
onSendPriceSheet: (order: WholesaleOrder) => void;
};
function OrdersList({
orders,
fulfilling,
openActions,
onToggleActions,
onFulfill,
onRecordDeposit,
onViewDetails,
onGenerateManifest,
onSendPriceSheet,
}: OrdersListProps) {
return (
<div className="space-y-3">
{orders.map((order) => (
<OrderCard
key={order.id}
order={order}
fulfilling={fulfilling}
openActions={openActions}
onToggleActions={() => onToggleActions(order.id)}
onFulfill={() => onFulfill(order.id)}
onRecordDeposit={() => onRecordDeposit(order.id)}
onViewDetails={() => onViewDetails(order)}
onGenerateManifest={() => onGenerateManifest(order)}
onSendPriceSheet={() => onSendPriceSheet(order)}
/>
))}
</div>
);
}
// ── Portal Modals ─────────────────────────────────────────────────────────────
function PortalModals({
state,
dispatch,
brandId,
handleFulfill,
}: {
state: State;
dispatch: React.Dispatch<Action>;
brandId: string;
handleFulfill: (orderId: string) => void;
}) {
const depositOrder = state.showDepForm
? state.orders.find((o) => o.id === state.showDepForm) ?? null
: null;
return (
<>
{state.showViewOrder && (
<OrderDetailsModal
order={state.showViewOrder}
onClose={() => dispatch({ type: "SET_SHOW_VIEW_ORDER", order: null })}
onFulfill={handleFulfill}
onRecordDeposit={(id) => {
dispatch({ type: "SET_SHOW_VIEW_ORDER", order: null });
dispatch({ type: "SET_SHOW_DEP_FORM", id });
}}
fulfilling={state.fulfilling}
/>
)}
{showDepForm && (() => {
const order = orders.find(o => o.id === showDepForm);
if (!order) return null;
return (
<DepositModal
order={order}
onClose={() => setShowDepForm(null)}
onFulfilled={async () => {
setMsg({ kind: "success", text: "Deposit recorded." });
const data = await getWholesalePickupOrders(brandId);
setOrders(data);
}}
/>
);
})()}
</div>
{depositOrder && (
<DepositModal
order={depositOrder}
onClose={() => dispatch({ type: "SET_SHOW_DEP_FORM", id: null })}
onFulfilled={async () => {
dispatch({ type: "SET_MSG", msg: { kind: "success", text: "Deposit recorded." } });
const data = await getWholesalePickupOrders(brandId);
dispatch({ type: "SET_ORDERS", orders: data });
}}
/>
)}
</>
);
}
@@ -254,14 +447,14 @@ function EmployeePortalSkeleton() {
<div className="bg-white border-b border-slate-200 px-6">
<div className="mx-auto max-w-5xl">
<div className="flex gap-1 -mb-px py-3">
{[1, 2, 3].map(i => (
{[1, 2, 3].map((i) => (
<div key={i} className="h-9 w-20 bg-slate-200 rounded-xl animate-pulse" />
))}
</div>
</div>
</div>
<div className="mx-auto max-w-5xl px-6 py-6 space-y-3">
{[1, 2, 3, 4].map(i => (
{[1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 animate-pulse">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 flex-1">
@@ -361,7 +554,7 @@ function OrderCard({
</button>
)}
{Number(order.balance_due) > 0 && order.fulfillment_status !== "fulfilled" && (
<button aria-label="Record deposit"
<button aria-label="Record deposit"
type="button"
onClick={onRecordDeposit} title="Record deposit"
className="inline-flex items-center gap-1.5 rounded-xl bg-purple-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-purple-700 transition-colors">
@@ -369,7 +562,7 @@ function OrderCard({
Deposit
</button>
)}
<a aria-label="Download Invoice (PDF)"
<a aria-label="Download Invoice (PDF)"
href={`/api/wholesale/invoice/${order.id}/pdf`}
target="_blank"
rel="noopener noreferrer" title="Download Invoice (PDF)"
@@ -377,7 +570,7 @@ function OrderCard({
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 110 12H15M6 2h9l4 4v14a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/></svg>
</a>
<div className="relative">
<button aria-label="More actions"
<button aria-label="More actions"
type="button"
onClick={onToggleActions} title="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
@@ -386,7 +579,7 @@ function OrderCard({
{openActions === order.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-56 rounded-xl bg-white shadow-xl ring-1 ring-slate-200 py-1 text-sm"
onClick={e => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={() => { onToggleActions(); onViewDetails(); }} className="w-full text-left px-4 py-3 text-slate-700 hover:bg-slate-50 flex items-center gap-3">
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
@@ -438,4 +631,4 @@ function OrderCard({
)}
</div>
);
}
}
File diff suppressed because it is too large Load Diff
+277 -167
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useReducer } from "react";
import { registerWholesaleCustomer } from "@/actions/wholesale-register";
import { useRouter } from "next/navigation";
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
@@ -31,20 +31,242 @@ function FormField({ label, id, error, hint, children }: {
);
}
// --- Reducer state -------------------------------------------------------
type Form = {
companyName: string;
contactName: string;
email: string;
phone: string;
brandId: string;
};
type State = {
checkingEnabled: boolean;
portalDisabled: boolean;
form: Form;
fieldErrors: Record<string, string>;
submitting: boolean;
result: { success: boolean; message: string } | null;
};
type Action =
| { type: "SET_CHECKING_ENABLED"; value: boolean }
| { type: "SET_PORTAL_DISABLED"; value: boolean }
| { type: "SET_FORM_FIELD"; field: keyof Form; value: string }
| { type: "SET_FIELD_ERRORS"; errors: Record<string, string> }
| { type: "CLEAR_FIELD_ERROR"; field: string }
| { type: "SET_SUBMITTING"; value: boolean }
| { type: "SET_RESULT"; result: { success: boolean; message: string } | null };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CHECKING_ENABLED":
return { ...state, checkingEnabled: action.value };
case "SET_PORTAL_DISABLED":
return { ...state, portalDisabled: action.value };
case "SET_FORM_FIELD":
return { ...state, form: { ...state.form, [action.field]: action.value } };
case "SET_FIELD_ERRORS":
return { ...state, fieldErrors: action.errors };
case "CLEAR_FIELD_ERROR":
return { ...state, fieldErrors: { ...state.fieldErrors, [action.field]: "" } };
case "SET_SUBMITTING":
return { ...state, submitting: action.value };
case "SET_RESULT":
return { ...state, result: action.result };
default:
return state;
}
}
function createInitialState(): State {
return {
checkingEnabled: true,
portalDisabled: false,
form: {
companyName: "",
contactName: "",
email: "",
phone: "",
brandId: TUXEDO_BRAND_ID,
},
fieldErrors: {},
submitting: false,
result: null,
};
}
// --- Subcomponents -------------------------------------------------------
type RegisterFormProps = {
form: Form;
fieldErrors: Record<string, string>;
submitting: boolean;
onChangeField: (field: keyof Form, value: string) => void;
onClearFieldError: (field: string) => void;
onSubmit: (e: React.FormEvent) => void;
};
function RegisterForm({
form,
fieldErrors,
submitting,
onChangeField,
onClearFieldError,
onSubmit,
}: RegisterFormProps) {
return (
<form onSubmit={onSubmit} className="space-y-4">
<FormField label="Company Name" id="companyName" error={fieldErrors.companyName} hint={null}>
<input aria-label="Farm Or Business Name"
id="companyName"
value={form.companyName}
onChange={e => { onChangeField("companyName", e.target.value); onClearFieldError("companyName"); }}
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.companyName ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="Farm or business name"
autoComplete="organization"
/>
</FormField>
<FormField label="Contact Name" id="contactName" error={null} hint="Optional — your name">
<input aria-label="Your Name"
id="contactName"
value={form.contactName}
onChange={e => onChangeField("contactName", e.target.value)}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
placeholder="Your name"
autoComplete="name"
/>
</FormField>
<FormField label="Email" id="email" error={fieldErrors.email} hint={null}>
<input aria-label="Order@company.com"
type="email"
id="email"
value={form.email}
onChange={e => { onChangeField("email", e.target.value); onClearFieldError("email"); }}
required
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.email ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="order@company.com"
autoComplete="email"
/>
</FormField>
<FormField label="Phone" id="phone" error={fieldErrors.phone} hint="Optional — for order coordination">
<input aria-label="(555) 555 5555"
type="tel"
id="phone"
value={form.phone}
onChange={e => { onChangeField("phone", e.target.value); onClearFieldError("phone"); }}
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.phone ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="(555) 555-5555"
autoComplete="tel"
/>
</FormField>
<FormField label="Brand" id="brandId" error={null} hint={null}>
<select aria-label="BrandId"
id="brandId"
value={form.brandId}
onChange={e => onChangeField("brandId", e.target.value)}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors appearance-none cursor-pointer"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center", backgroundSize: "16px" }}
>
<option value={TUXEDO_BRAND_ID}>Tuxedo Corn Colorado Sweet Corn</option>
<option value={IRD_BRAND_ID}>Indian River Direct Florida Citrus</option>
</select>
</FormField>
<button
type="submit"
disabled={submitting}
className="w-full rounded-xl bg-emerald-600 py-3.5 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-3 flex items-center justify-center gap-2"
>
{submitting ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Submitting...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Submit Application
</>
)}
</button>
</form>
);
}
type ResultBannerProps = {
result: { success: boolean; message: string } | null;
};
function ResultBanner({ result }: ResultBannerProps) {
if (!result) return null;
return (
<div className={`mb-5 rounded-xl border px-4 py-4 text-sm flex items-start gap-2 ${
result.success
? "border-emerald-900/50 bg-emerald-950/50 text-emerald-400"
: "border-red-900/50 bg-red-950/50 text-red-400"
}`}>
{result.success ? (
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
) : (
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
)}
{result.message}
</div>
);
}
function SuccessPanel() {
return (
<div className="text-center py-4">
<div className="w-16 h-16 bg-emerald-900/40 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</div>
<Link
href="/wholesale/login"
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-bold text-white hover:bg-emerald-500"
>
Go to Sign In
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
</Link>
</div>
);
}
function LoadingScreen() {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-center">
<div className="w-10 h-10 rounded-full border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mx-auto mb-4" />
<p className="text-zinc-500 text-sm">Loading...</p>
</div>
</div>
);
}
// --- Main component ------------------------------------------------------
export default function WholesaleRegisterPage() {
const router = useRouter();
const [checkingEnabled, setCheckingEnabled] = useState(true);
const [portalDisabled, setPortalDisabled] = useState(false);
const [form, setForm] = useState({
companyName: "",
contactName: "",
email: "",
phone: "",
brandId: TUXEDO_BRAND_ID,
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
const [state, dispatch] = useReducer(reducer, createInitialState());
useEffect(() => {
async function checkEnabled(brandId: string) {
@@ -55,24 +277,24 @@ export default function WholesaleRegisterPage() {
.eq("brand_id", brandId)
.single();
if (ws && ws.wholesale_enabled === false) {
setPortalDisabled(true);
dispatch({ type: "SET_PORTAL_DISABLED", value: true });
}
setCheckingEnabled(false);
dispatch({ type: "SET_CHECKING_ENABLED", value: false });
}
checkEnabled(form.brandId);
}, [form.brandId]);
checkEnabled(state.form.brandId);
}, [state.form.brandId]);
function validateForm() {
const errors: Record<string, string> = {};
if (!form.companyName.trim()) {
if (!state.form.companyName.trim()) {
errors.companyName = "Company name is required";
}
if (!form.email) {
if (!state.form.email) {
errors.email = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.form.email)) {
errors.email = "Enter a valid email address";
}
if (form.phone && !/^[\d\s\-\+\(\)]+$/.test(form.phone)) {
if (state.form.phone && !/^[\d\s\-\+\(\)]+$/.test(state.form.phone)) {
errors.phone = "Enter a valid phone number";
}
return errors;
@@ -82,43 +304,39 @@ export default function WholesaleRegisterPage() {
e.preventDefault();
const errors = validateForm();
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
dispatch({ type: "SET_FIELD_ERRORS", errors });
return;
}
setSubmitting(true);
setResult(null);
setFieldErrors({});
dispatch({ type: "SET_SUBMITTING", value: true });
dispatch({ type: "SET_RESULT", result: null });
dispatch({ type: "SET_FIELD_ERRORS", errors: {} });
const res = await registerWholesaleCustomer({
brandId: form.brandId,
companyName: form.companyName,
contactName: form.contactName || undefined,
email: form.email,
phone: form.phone || undefined,
brandId: state.form.brandId,
companyName: state.form.companyName,
contactName: state.form.contactName || undefined,
email: state.form.email,
phone: state.form.phone || undefined,
});
setSubmitting(false);
dispatch({ type: "SET_SUBMITTING", value: false });
if (res.success) {
setResult({
success: true,
message: res.requiresApproval
? "Application submitted — we'll review your account and notify you by email within 12 business days."
: "Account created — you can now log in.",
dispatch({
type: "SET_RESULT",
result: {
success: true,
message: res.requiresApproval
? "Application submitted — we'll review your account and notify you by email within 12 business days."
: "Account created — you can now log in.",
},
});
} else {
setResult({ success: false, message: res.error ?? "Registration failed. Please try again." });
dispatch({ type: "SET_RESULT", result: { success: false, message: res.error ?? "Registration failed. Please try again." } });
}
}
if (checkingEnabled) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-center">
<div className="w-10 h-10 rounded-full border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mx-auto mb-4" />
<p className="text-zinc-500 text-sm">Loading...</p>
</div>
</div>
);
if (state.checkingEnabled) {
return <LoadingScreen />;
}
return (
@@ -162,7 +380,7 @@ export default function WholesaleRegisterPage() {
{/* Form column */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 shadow-xl shadow-black/20">
{portalDisabled && (
{state.portalDisabled && (
<div className="mb-5 rounded-xl border border-amber-900/50 bg-amber-950/50 px-4 py-3 text-sm text-amber-400 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
@@ -176,130 +394,22 @@ export default function WholesaleRegisterPage() {
<p className="mt-1 text-sm text-zinc-500">We&apos;ll review and respond within 12 business days.</p>
</div>
{result && (
<div className={`mb-5 rounded-xl border px-4 py-4 text-sm flex items-start gap-2 ${
result.success
? "border-emerald-900/50 bg-emerald-950/50 text-emerald-400"
: "border-red-900/50 bg-red-950/50 text-red-400"
}`}>
{result.success ? (
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
) : (
<svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
)}
{result.message}
</div>
)}
<ResultBanner result={state.result} />
{result?.success ? (
<div className="text-center py-4">
<div className="w-16 h-16 bg-emerald-900/40 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</div>
<Link
href="/wholesale/login"
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-bold text-white hover:bg-emerald-500"
>
Go to Sign In
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
</Link>
</div>
{state.result?.success ? (
<SuccessPanel />
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label="Company Name" id="companyName" error={fieldErrors.companyName} hint={null}>
<input aria-label="Farm Or Business Name"
id="companyName"
value={form.companyName}
onChange={e => { setForm(f => ({ ...f, companyName: e.target.value })); setFieldErrors(f => ({ ...f, companyName: "" })); }}
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.companyName ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="Farm or business name"
autoComplete="organization"
/>
</FormField>
<FormField label="Contact Name" id="contactName" error={null} hint="Optional — your name">
<input aria-label="Your Name"
id="contactName"
value={form.contactName}
onChange={e => setForm(f => ({ ...f, contactName: e.target.value }))}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
placeholder="Your name"
autoComplete="name"
/>
</FormField>
<FormField label="Email" id="email" error={fieldErrors.email} hint={null}>
<input aria-label="Order@company.com"
type="email"
id="email"
value={form.email}
onChange={e => { setForm(f => ({ ...f, email: e.target.value })); setFieldErrors(f => ({ ...f, email: "" })); }}
required
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.email ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="order@company.com"
autoComplete="email"
/>
</FormField>
<FormField label="Phone" id="phone" error={fieldErrors.phone} hint="Optional — for order coordination">
<input aria-label="(555) 555 5555"
type="tel"
id="phone"
value={form.phone}
onChange={e => { setForm(f => ({ ...f, phone: e.target.value })); setFieldErrors(f => ({ ...f, phone: "" })); }}
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.phone ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="(555) 555-5555"
autoComplete="tel"
/>
</FormField>
<FormField label="Brand" id="brandId" error={null} hint={null}>
<select aria-label="BrandId"
id="brandId"
value={form.brandId}
onChange={e => setForm(f => ({ ...f, brandId: e.target.value }))}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors appearance-none cursor-pointer"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center", backgroundSize: "16px" }}
>
<option value={TUXEDO_BRAND_ID}>Tuxedo Corn Colorado Sweet Corn</option>
<option value={IRD_BRAND_ID}>Indian River Direct Florida Citrus</option>
</select>
</FormField>
<button
type="submit"
disabled={submitting}
className="w-full rounded-xl bg-emerald-600 py-3.5 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-3 flex items-center justify-center gap-2"
>
{submitting ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Submitting...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Submit Application
</>
)}
</button>
</form>
<RegisterForm
form={state.form}
fieldErrors={state.fieldErrors}
submitting={state.submitting}
onChangeField={(field, value) => dispatch({ type: "SET_FORM_FIELD", field, value })}
onClearFieldError={(field) => dispatch({ type: "CLEAR_FIELD_ERROR", field })}
onSubmit={handleSubmit}
/>
)}
{!result?.success && (
{!state.result?.success && (
<div className="mt-5 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
<p className="text-sm text-zinc-400">
Already have an account?{" "}